From 228fb03a09a7573ff92992ac0dcb60acc88e9e1f Mon Sep 17 00:00:00 2001 From: Ed Harrod Date: Tue, 18 Aug 2026 17:13:42 +0100 Subject: [PATCH] feat: inject OpenRouter provider.only for BYOK / no-fallback routing Add OPENROUTER_PROVIDER_ONLY env var (comma-separated provider slugs) that the proxy injects as {"provider": {"only": [...]}} into every request body when the detected provider is OpenRouter. This pins routing to specific upstream provider(s) so requests use the user's BYOK key and fail hard instead of silently falling back to a credit-billed provider (e.g. Bedrock/Vertex). allow_fallback is intentionally omitted: OpenRouter rejects it as an unrecognized key inside provider, and only already implies fail-hard. Note: provider.only constrains the provider slug only. It does not prevent within-slug fallback from a BYOK endpoint to OpenRouter's own pool for the same slug; that still requires the "Always use for this provider" toggle on a Prioritized BYOK key. Closes #8 Co-Authored-By: Claude --- internal/config/config.go | 30 ++++++++++++++++++++++++++++++ internal/converter/converter.go | 13 +++++++++++++ pkg/models/types.go | 3 ++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 1043d94..f39b60d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,6 +77,13 @@ type Config struct { // OpenRouter-specific (optional, improves rate limits) OpenRouterAppName string OpenRouterAppURL string + + // OpenRouter provider routing - pins requests to specific upstream provider(s) + // so OpenRouter uses your BYOK key and fails hard instead of falling back to a + // credit-billed provider (e.g. Bedrock/Vertex). Set via OPENROUTER_PROVIDER_ONLY + // (comma-separated provider slugs, e.g. "google-ai-studio"). When set, the proxy + // injects {"provider": {"only": [...], "allow_fallback": false}} into the request body. + OpenRouterProviderOnly []string } // Load reads configuration from environment variables @@ -120,6 +127,9 @@ func Load() (*Config, error) { // OpenRouter-specific (optional) OpenRouterAppName: os.Getenv("OPENROUTER_APP_NAME"), OpenRouterAppURL: os.Getenv("OPENROUTER_APP_URL"), + + // OpenRouter provider pinning (optional). Comma-separated provider slugs. + OpenRouterProviderOnly: parseProviderOnly(os.Getenv("OPENROUTER_PROVIDER_ONLY")), } // Validate required fields @@ -160,6 +170,26 @@ func getEnvAsBoolOrDefault(key string, defaultValue bool) bool { return defaultValue } +// parseProviderOnly splits a comma-separated list of OpenRouter provider slugs +// (e.g. "google-ai-studio") into a clean slice, trimming whitespace and dropping +// empties. Returns nil when unset, so the proxy skips provider injection entirely. +func parseProviderOnly(value string) []string { + if value == "" { + return nil + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if slug := strings.TrimSpace(p); slug != "" { + result = append(result, slug) + } + } + if len(result) == 0 { + return nil + } + return result +} + // DetectProvider identifies the provider type based on base URL func (c *Config) DetectProvider() ProviderType { baseURL := strings.ToLower(c.OpenAIBaseURL) diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 0b44468..43de4ea 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -137,6 +137,19 @@ func ConvertRequest(claudeReq models.ClaudeRequest, cfg *config.Config) (*models } } + // Pin provider when configured: forces OpenRouter to use the user's BYOK key for + // the named provider(s) and fail hard instead of falling back to a credit-billed + // upstream (e.g. Bedrock/Vertex). Also keeps routing stable so upstream prefix + // caches stay warm. Applies to both streaming and non-streaming requests. + if cfg.DetectProvider() == config.ProviderOpenRouter && len(cfg.OpenRouterProviderOnly) > 0 { + // "only" pins routing to the named provider(s) and fails hard if none can + // serve the request, rather than falling back to a credit-billed upstream. + // (OpenRouter rejects an "allow_fallback" key here; "only" already prevents fallback.) + openaiReq.Provider = map[string]interface{}{ + "only": cfg.OpenRouterProviderOnly, + } + } + // Set token limit using adaptive per-model detection if claudeReq.MaxTokens > 0 { // Use capability-based detection - NO hardcoded model patterns! diff --git a/pkg/models/types.go b/pkg/models/types.go index 58f1d7d..b51899a 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -71,7 +71,8 @@ type OpenAIRequest struct { Reasoning map[string]interface{} `json:"reasoning,omitempty"` // OpenRouter reasoning tokens ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI Chat Completions reasoning (GPT-5 models) Tools []OpenAITool `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` // Force tool usage: "auto", "required", or specific tool + ToolChoice interface{} `json:"tool_choice,omitempty"` // Force tool usage: "auto", "required", or specific tool + Provider map[string]interface{} `json:"provider,omitempty"` // OpenRouter provider routing (e.g. {"only": ["google-ai-studio"], "allow_fallback": false}) } // OpenAITool represents a tool in OpenAI format