From 4b579bf1215ba76a926fc0b5d55342c249b28f51 Mon Sep 17 00:00:00 2001 From: ChethanUK Date: Wed, 8 Jul 2026 19:54:45 +0200 Subject: [PATCH 1/5] feat(llm): add built-in Google Gemini provider preset Adds a preset for Google's Gemini API via its OpenAI-compatible endpoint (https://generativelanguage.googleapis.com/v1beta/openai), routed through the existing OpenAIClient path. Model list reflects the current Gemini 3.x lineup. Introduces the LegacyMaxTokens Provider field (wired into request building in a follow-up commit). Closes #49 --- internal/llm/providers.go | 19 +++++++++++++++++++ internal/llm/providers_test.go | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/llm/providers.go b/internal/llm/providers.go index 9a80981f..efdb5c68 100644 --- a/internal/llm/providers.go +++ b/internal/llm/providers.go @@ -14,6 +14,10 @@ type Provider struct { AuthHeader string // Anthropic-only; empty for OpenAI-compatible EnvVar string // environment variable name for API key fallback Models []string + + // LegacyMaxTokens forces the legacy `max_tokens` request field instead of + // `max_completion_tokens` for OpenAI-compatible endpoints that require it. + LegacyMaxTokens bool } var registry = []Provider{ @@ -109,6 +113,21 @@ var registry = []Provider{ "deepseek-v4-flash", }, }, + { + Name: "gemini", + DisplayName: "Google Gemini API", + Protocol: "openai", + BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + EnvVar: "GEMINI_API_KEY", + LegacyMaxTokens: true, + Models: []string{ + "gemini-3.1-pro-preview", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite", + "gemini-2.5-pro", + "gemini-2.5-flash", + }, + }, { Name: "tencent-tokenhub", DisplayName: "Tencent TokenHub API", diff --git a/internal/llm/providers_test.go b/internal/llm/providers_test.go index 4f3fdf42..a3b5c3bf 100644 --- a/internal/llm/providers_test.go +++ b/internal/llm/providers_test.go @@ -40,7 +40,7 @@ func TestListProviders_Order(t *testing.T) { if len(providers) < 3 { t.Fatalf("expected at least 3 providers, got %d", len(providers)) } - expected := []string{"anthropic", "baidu-qianfan", "dashscope", "dashscope-tokenplan", "deepseek", "hy-tokenplan", "kimi", "mimo", "minimax", "openai", "tencent-tokenhub", "volcengine", "z-ai", "z-ai-coding"} + expected := []string{"anthropic", "baidu-qianfan", "dashscope", "dashscope-tokenplan", "deepseek", "gemini", "hy-tokenplan", "kimi", "mimo", "minimax", "openai", "tencent-tokenhub", "volcengine", "z-ai", "z-ai-coding"} if len(providers) != len(expected) { t.Fatalf("expected %d providers, got %d", len(expected), len(providers)) } @@ -126,3 +126,34 @@ func TestLookupProvider_OpenAIDetails(t *testing.T) { t.Errorf("AuthHeader = %q, want empty", p.AuthHeader) } } + +func TestLookupProvider_GeminiDetails(t *testing.T) { + p, ok := LookupProvider("gemini") + if !ok { + t.Fatal("gemini not found") + } + if p.Protocol != "openai" { + t.Errorf("Protocol = %q, want %q", p.Protocol, "openai") + } + if p.BaseURL != "https://generativelanguage.googleapis.com/v1beta/openai" { + t.Errorf("BaseURL = %q, want %q", p.BaseURL, "https://generativelanguage.googleapis.com/v1beta/openai") + } + if p.EnvVar != "GEMINI_API_KEY" { + t.Errorf("EnvVar = %q, want %q", p.EnvVar, "GEMINI_API_KEY") + } + if p.AuthHeader != "" { + t.Errorf("AuthHeader = %q, want empty", p.AuthHeader) + } + if !p.LegacyMaxTokens { + t.Errorf("LegacyMaxTokens = %v, want true", p.LegacyMaxTokens) + } + expected := []string{"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite", "gemini-2.5-pro", "gemini-2.5-flash"} + if len(p.Models) != len(expected) { + t.Fatalf("expected %d models, got %d", len(expected), len(p.Models)) + } + for i, model := range expected { + if p.Models[i] != model { + t.Errorf("Models[%d] = %q, want %q", i, p.Models[i], model) + } + } +} From 896257dfc9917a10e7985cc9f96454ab2fc2686a Mon Sep 17 00:00:00 2001 From: ChethanUK Date: Wed, 8 Jul 2026 19:58:24 +0200 Subject: [PATCH 2/5] fix(llm): send max_tokens instead of max_completion_tokens for Gemini preset Gemini's OpenAI-compatible endpoint mishandles the newer max_completion_tokens field. Thread the Provider.LegacyMaxTokens preset flag through ResolvedEndpoint and ClientConfig so buildOpenAIParams emits the legacy max_tokens field when the preset requires it. No behavior change for existing presets (LegacyMaxTokens defaults false). --- internal/llm/client.go | 11 +++++++++- internal/llm/client_test.go | 40 +++++++++++++++++++++++++++++++++++++ internal/llm/resolver.go | 24 +++++++++++++--------- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/internal/llm/client.go b/internal/llm/client.go index 685c7170..b7342ec1 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -186,6 +186,9 @@ type ClientConfig struct { Timeout time.Duration // Request timeout ExtraBody map[string]any // Vendor-specific fields merged into every request body ExtraHeaders map[string]string // Extra HTTP headers sent with every request + // LegacyMaxTokens sends the legacy `max_tokens` request field instead of + // `max_completion_tokens` (required by Gemini's OpenAI-compatible endpoint). + LegacyMaxTokens bool } // --- Factory --- @@ -201,6 +204,8 @@ func NewLLMClient(ep ResolvedEndpoint) LLMClient { Timeout: ep.Timeout, ExtraBody: ep.ExtraBody, ExtraHeaders: ep.ExtraHeaders, + + LegacyMaxTokens: ep.LegacyMaxTokens, } if ep.Protocol == "anthropic" { return NewAnthropicClient(cfg) @@ -400,7 +405,11 @@ func (c *OpenAIClient) buildOpenAIParams(model string, req ChatRequest) openai.C params.Tools = tools } if req.MaxTokens > 0 { - params.MaxCompletionTokens = openai.Int(int64(req.MaxTokens)) + if c.cfg.LegacyMaxTokens { + params.MaxTokens = openai.Int(int64(req.MaxTokens)) + } else { + params.MaxCompletionTokens = openai.Int(int64(req.MaxTokens)) + } } if req.Temperature != nil { params.Temperature = openai.Float(*req.Temperature) diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 50db73c7..b7fdc9c3 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -95,6 +95,46 @@ func TestNewAnthropicClient_URLNormalization(t *testing.T) { } } +func TestBuildOpenAIParams_LegacyMaxTokens(t *testing.T) { + tests := []struct { + name string + legacyMaxTokens bool + }{ + {name: "legacy sends max_tokens", legacyMaxTokens: true}, + {name: "modern sends max_completion_tokens", legacyMaxTokens: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewOpenAIClient(ClientConfig{ + URL: "https://api.example.com/v1", + LegacyMaxTokens: tt.legacyMaxTokens, + }) + + params := client.buildOpenAIParams("some-model", ChatRequest{ + Messages: []Message{{Role: "user", Content: "hi"}}, + MaxTokens: 1000, + }) + + if tt.legacyMaxTokens { + if !params.MaxTokens.Valid() || params.MaxTokens.Value != 1000 { + t.Errorf("MaxTokens = %+v (valid=%v), want 1000", params.MaxTokens.Value, params.MaxTokens.Valid()) + } + if params.MaxCompletionTokens.Valid() { + t.Errorf("MaxCompletionTokens = %+v, want unset", params.MaxCompletionTokens.Value) + } + } else { + if !params.MaxCompletionTokens.Valid() || params.MaxCompletionTokens.Value != 1000 { + t.Errorf("MaxCompletionTokens = %+v (valid=%v), want 1000", params.MaxCompletionTokens.Value, params.MaxCompletionTokens.Valid()) + } + if params.MaxTokens.Valid() { + t.Errorf("MaxTokens = %+v, want unset", params.MaxTokens.Value) + } + } + }) + } +} + func TestBuildAnthropicParams_CacheControl(t *testing.T) { client := NewAnthropicClient(ClientConfig{URL: "https://api.anthropic.com"}) diff --git a/internal/llm/resolver.go b/internal/llm/resolver.go index 19c85591..836a2c26 100644 --- a/internal/llm/resolver.go +++ b/internal/llm/resolver.go @@ -22,6 +22,9 @@ type ResolvedEndpoint struct { Source string // human-readable config source label ExtraBody map[string]any // vendor-specific request body fields ExtraHeaders map[string]string // extra HTTP headers for the LLM request + // LegacyMaxTokens sends the legacy `max_tokens` request field instead of + // `max_completion_tokens`. Set from a provider preset (e.g. Gemini). + LegacyMaxTokens bool // Timeout is the per-request HTTP timeout; 0 means use the client default (5 min). // Only config file (llm/provider sections) and OCR_LLM_TIMEOUT env var can set this. // tryCCEnv and tryShellRC always leave it at 0 since those sources have no timeout @@ -271,11 +274,13 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, var url, protocol, authHeader, model string var extraBody map[string]any + var legacyMaxTokens bool if isPreset { url = preset.BaseURL protocol = preset.Protocol authHeader = preset.AuthHeader + legacyMaxTokens = preset.LegacyMaxTokens if entry.URL != "" { url = entry.URL } @@ -360,15 +365,16 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint, } return ResolvedEndpoint{ - URL: url, - Token: apiKey, - Model: model, - Protocol: protocol, - AuthHeader: authHeader, - Source: "provider:" + cfg.Provider, - ExtraBody: extraBody, - ExtraHeaders: extraHeaders, - Timeout: timeout, + URL: url, + Token: apiKey, + Model: model, + Protocol: protocol, + AuthHeader: authHeader, + Source: "provider:" + cfg.Provider, + ExtraBody: extraBody, + ExtraHeaders: extraHeaders, + Timeout: timeout, + LegacyMaxTokens: legacyMaxTokens, }, true, nil } From f63981fdab628a78775ea82b167b1a8d3a94b883 Mon Sep 17 00:00:00 2001 From: ChethanUK Date: Wed, 8 Jul 2026 20:00:22 +0200 Subject: [PATCH 3/5] docs(pages): add Gemini to built-in provider tables Documents the new gemini preset (openai protocol, GEMINI_API_KEY) in the en/zh/ja configuration provider tables. --- pages/src/content/docs/en/configuration.md | 1 + pages/src/content/docs/ja/configuration.md | 1 + pages/src/content/docs/zh/configuration.md | 1 + 3 files changed, 3 insertions(+) diff --git a/pages/src/content/docs/en/configuration.md b/pages/src/content/docs/en/configuration.md index b8443cb7..3ac1bebb 100644 --- a/pages/src/content/docs/en/configuration.md +++ b/pages/src/content/docs/en/configuration.md @@ -50,6 +50,7 @@ environment variable. | `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` | | `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` | | `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` | +| `gemini` | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | `GEMINI_API_KEY` | | `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` | | `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` | | `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` | diff --git a/pages/src/content/docs/ja/configuration.md b/pages/src/content/docs/ja/configuration.md index f60f5697..0ba5b7ca 100644 --- a/pages/src/content/docs/ja/configuration.md +++ b/pages/src/content/docs/ja/configuration.md @@ -48,6 +48,7 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx | `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` | | `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` | | `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` | +| `gemini` | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | `GEMINI_API_KEY` | | `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` | | `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` | | `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` | diff --git a/pages/src/content/docs/zh/configuration.md b/pages/src/content/docs/zh/configuration.md index 51ad3b1d..a2287864 100644 --- a/pages/src/content/docs/zh/configuration.md +++ b/pages/src/content/docs/zh/configuration.md @@ -47,6 +47,7 @@ ocr config set providers.anthropic.api_key sk-ant-xxxxxxxxxx | `dashscope-tokenplan` | openai | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_TOKENPLAN_KEY` | | `volcengine` | openai | `https://ark.cn-beijing.volces.com/api/v3` | `ARK_API_KEY` | | `deepseek` | openai | `https://api.deepseek.com` | `DEEPSEEK_API_KEY` | +| `gemini` | openai | `https://generativelanguage.googleapis.com/v1beta/openai` | `GEMINI_API_KEY` | | `tencent-tokenhub` | openai | `https://tokenhub.tencentmaas.com/v1` | `TENCENT_TOKENHUB_API_KEY` | | `hy-tokenplan` | openai | `https://api.lkeap.cloud.tencent.com/plan/v3` | `TENCENT_HUNYUAN_TOKENPLAN_KEY` | | `kimi` | openai | `https://api.moonshot.cn/v1` | `MOONSHOT_API_KEY` | From 5db7e5eafdc9be1bf3a0a0314a299c3abc80021a Mon Sep 17 00:00:00 2001 From: ChethanUK Date: Wed, 8 Jul 2026 20:22:20 +0200 Subject: [PATCH 4/5] fix(llm): ship only tool-call-verified Gemini models Live smoke testing revealed all Gemini 3.x preview models (gemini-3.1-pro-preview, gemini-3-flash-preview, gemini-3.1-flash-lite) return HTTP 400 on OCR's multi-turn tool-calling review path: Gemini 3.x tool-call responses carry a google.thought_signature in extra_content that must be echoed back on the next turn, which the OpenAI-compatible client does not yet do. They pass a plain llm test but break real reviews. Ship only the GA Gemini 2.5 models (gemini-2.5-flash, gemini-2.5-pro), both verified end-to-end on the tool-calling review path. Re-enabling 3.x would require thought_signature handling in the OpenAI-compat client. --- internal/llm/providers.go | 8 ++++---- internal/llm/providers_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/llm/providers.go b/internal/llm/providers.go index efdb5c68..0b48e631 100644 --- a/internal/llm/providers.go +++ b/internal/llm/providers.go @@ -121,11 +121,11 @@ var registry = []Provider{ EnvVar: "GEMINI_API_KEY", LegacyMaxTokens: true, Models: []string{ - "gemini-3.1-pro-preview", - "gemini-3-flash-preview", - "gemini-3.1-flash-lite", - "gemini-2.5-pro", + // Gemini 3.x -preview models 400 on OCR's multi-turn tool-calling review + // path (thought_signature must be echoed back, which the OpenAI-compat client + // does not yet do). Ship only the GA 2.5 models, verified end-to-end. "gemini-2.5-flash", + "gemini-2.5-pro", }, }, { diff --git a/internal/llm/providers_test.go b/internal/llm/providers_test.go index a3b5c3bf..e9b08011 100644 --- a/internal/llm/providers_test.go +++ b/internal/llm/providers_test.go @@ -147,7 +147,7 @@ func TestLookupProvider_GeminiDetails(t *testing.T) { if !p.LegacyMaxTokens { t.Errorf("LegacyMaxTokens = %v, want true", p.LegacyMaxTokens) } - expected := []string{"gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3.1-flash-lite", "gemini-2.5-pro", "gemini-2.5-flash"} + expected := []string{"gemini-2.5-flash", "gemini-2.5-pro"} if len(p.Models) != len(expected) { t.Fatalf("expected %d models, got %d", len(expected), len(p.Models)) } From 1bfbd83b959d54e0ae9d433abfa33f4f2022a329 Mon Sep 17 00:00:00 2001 From: ChethanUK Date: Wed, 8 Jul 2026 20:33:48 +0200 Subject: [PATCH 5/5] test(llm): cover LegacyMaxTokens threading from preset to client A cross-model review noted the preset -> ResolvedEndpoint -> ClientConfig wiring for LegacyMaxTokens was unguarded: a dropped assignment would keep tests green while a provider:gemini user silently sends max_completion_tokens. Adds a resolver test asserting the gemini preset resolves with LegacyMaxTokens=true (and non-preset stays false), and a MaxTokens==0 case that both token fields stay unset. --- internal/llm/client_test.go | 60 +++++++++++++++++++++++++++---- internal/llm/resolver_test.go | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index b7fdc9c3..bf9ed520 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -99,9 +99,14 @@ func TestBuildOpenAIParams_LegacyMaxTokens(t *testing.T) { tests := []struct { name string legacyMaxTokens bool + maxTokens int }{ - {name: "legacy sends max_tokens", legacyMaxTokens: true}, - {name: "modern sends max_completion_tokens", legacyMaxTokens: false}, + {name: "legacy sends max_tokens", legacyMaxTokens: true, maxTokens: 1000}, + {name: "modern sends max_completion_tokens", legacyMaxTokens: false, maxTokens: 1000}, + // MaxTokens==0 must leave BOTH fields unset (gates the `req.MaxTokens > 0` guard), + // regardless of the legacy flag. + {name: "legacy with zero max_tokens sets neither", legacyMaxTokens: true, maxTokens: 0}, + {name: "modern with zero max_tokens sets neither", legacyMaxTokens: false, maxTokens: 0}, } for _, tt := range tests { @@ -113,19 +118,29 @@ func TestBuildOpenAIParams_LegacyMaxTokens(t *testing.T) { params := client.buildOpenAIParams("some-model", ChatRequest{ Messages: []Message{{Role: "user", Content: "hi"}}, - MaxTokens: 1000, + MaxTokens: tt.maxTokens, }) + if tt.maxTokens == 0 { + if params.MaxTokens.Valid() { + t.Errorf("MaxTokens = %+v, want unset when MaxTokens==0", params.MaxTokens.Value) + } + if params.MaxCompletionTokens.Valid() { + t.Errorf("MaxCompletionTokens = %+v, want unset when MaxTokens==0", params.MaxCompletionTokens.Value) + } + return + } + if tt.legacyMaxTokens { - if !params.MaxTokens.Valid() || params.MaxTokens.Value != 1000 { - t.Errorf("MaxTokens = %+v (valid=%v), want 1000", params.MaxTokens.Value, params.MaxTokens.Valid()) + if !params.MaxTokens.Valid() || params.MaxTokens.Value != int64(tt.maxTokens) { + t.Errorf("MaxTokens = %+v (valid=%v), want %d", params.MaxTokens.Value, params.MaxTokens.Valid(), tt.maxTokens) } if params.MaxCompletionTokens.Valid() { t.Errorf("MaxCompletionTokens = %+v, want unset", params.MaxCompletionTokens.Value) } } else { - if !params.MaxCompletionTokens.Valid() || params.MaxCompletionTokens.Value != 1000 { - t.Errorf("MaxCompletionTokens = %+v (valid=%v), want 1000", params.MaxCompletionTokens.Value, params.MaxCompletionTokens.Valid()) + if !params.MaxCompletionTokens.Valid() || params.MaxCompletionTokens.Value != int64(tt.maxTokens) { + t.Errorf("MaxCompletionTokens = %+v (valid=%v), want %d", params.MaxCompletionTokens.Value, params.MaxCompletionTokens.Valid(), tt.maxTokens) } if params.MaxTokens.Valid() { t.Errorf("MaxTokens = %+v, want unset", params.MaxTokens.Value) @@ -135,6 +150,37 @@ func TestBuildOpenAIParams_LegacyMaxTokens(t *testing.T) { } } +// TestNewLLMClient_LegacyMaxTokensForwarded guards the ResolvedEndpoint -> ClientConfig +// hop: a dropped assignment in NewLLMClient would silently send max_completion_tokens +// to a Gemini endpoint (400). client_test.go is in-package, so we can read cfg directly. +func TestNewLLMClient_LegacyMaxTokensForwarded(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {name: "legacy true forwarded", want: true}, + {name: "legacy false forwarded", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewLLMClient(ResolvedEndpoint{ + URL: "https://api.example.com/v1", + Token: "test-token", + Model: "test-model", + Protocol: "openai", + LegacyMaxTokens: tt.want, + }) + oc, ok := client.(*OpenAIClient) + if !ok { + t.Fatalf("expected *OpenAIClient, got %T", client) + } + if oc.cfg.LegacyMaxTokens != tt.want { + t.Errorf("OpenAIClient cfg.LegacyMaxTokens = %v, want %v", oc.cfg.LegacyMaxTokens, tt.want) + } + }) + } +} + func TestBuildAnthropicParams_CacheControl(t *testing.T) { client := NewAnthropicClient(ClientConfig{URL: "https://api.anthropic.com"}) diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 6c933aa8..94cc8e2a 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -334,6 +334,74 @@ func TestResolveEndpoint_ProviderOpenAI(t *testing.T) { } } +// TestResolveEndpoint_ProviderLegacyMaxTokens guards the preset -> ResolvedEndpoint hop +// through the same public path real usage takes: resolving provider:gemini must yield +// LegacyMaxTokens=true, while presets/custom providers that don't opt in stay false. +// A dropped assignment here would keep all other tests green while Gemini users silently +// send max_completion_tokens and get 400s. +func TestResolveEndpoint_ProviderLegacyMaxTokens(t *testing.T) { + tests := []struct { + name string + cfg configFile + want bool + }{ + { + name: "gemini preset resolves with legacy max_tokens", + cfg: configFile{ + Provider: "gemini", + Providers: map[string]providerEntryConfig{ + "gemini": {APIKey: "gemini-key", Model: "gemini-2.5-flash"}, + }, + }, + want: true, + }, + { + name: "anthropic preset does not set legacy max_tokens", + cfg: configFile{ + Provider: "anthropic", + Providers: map[string]providerEntryConfig{ + "anthropic": {APIKey: "sk-ant-test", Model: "claude-sonnet-4-6"}, + }, + }, + want: false, + }, + { + name: "custom provider does not set legacy max_tokens", + cfg: configFile{ + Provider: "my-gateway", + CustomProviders: map[string]providerEntryConfig{ + "my-gateway": { + APIKey: "token", + URL: "https://gateway.internal.com/v1", + Protocol: "openai", + Model: "llama-3-70b", + }, + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearAllEnv(t) + data, _ := json.Marshal(tt.cfg) + cfgPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + ep, err := ResolveEndpoint(cfgPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ep.LegacyMaxTokens != tt.want { + t.Errorf("LegacyMaxTokens = %v, want %v", ep.LegacyMaxTokens, tt.want) + } + }) + } +} + func TestResolveEndpoint_ProviderModelOverride(t *testing.T) { clearAllEnv(t)