diff --git a/CHANGELOG.md b/CHANGELOG.md index 28273fd..1d23a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,43 @@ and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v > Tags prior to **v0.4.0** were cut in the private repository and produced no > public artifacts; the first publicly released version is v0.4.0. +## [1.4.1] - 2026-05-30 + +### Changed +- **`setup` no longer forces an API-key re-entry when one already exists.** + Re-running `commitbrief setup` for a provider that already has a key in the + target config now lets you leave the key prompt blank to keep the stored + key, so switching only the active provider/model doesn't require retyping + credentials. First-time configuration (no existing key) still requires a + non-empty key. For a non-interactive path, `providers use ` switches + the active provider and `config set providers..model ` changes + the model — both leave API keys untouched. + +### Added +- **OpenAI: GPT-5 family support — `gpt-5.5`, `gpt-5.4-mini`, `gpt-5.5-pro`.** + All three are selectable in `commitbrief setup` and carry correct context + windows (1.05M / 400K / 1.05M input) and pricing. The new default OpenAI + model is **`gpt-5.4-mini`** (was `gpt-4o`); existing configs that pin a + model are unaffected. `gpt-5.5-pro` is served through OpenAI's Responses + API (it is not available on Chat Completions) and may take several minutes + per review. Reasoning models get a larger default output-token budget so a + findings JSON isn't truncated by reasoning tokens. +- **Gemini: `gemini-3.5-flash` support.** + +### Changed +- **Gemini model lineup refreshed to the 3.x family.** `gemini-2.5-pro` → + `gemini-3.1-pro-preview`, `gemini-2.5-flash` → `gemini-3.5-flash`, + `gemini-1.5-flash` → `gemini-3.1-flash-lite`. The new default Gemini model + is **`gemini-3.5-flash`** (was `gemini-2.5-pro`). Pricing and context + windows updated; `gemini-3.1-pro-preview` carries tiered pricing (the + ≤200K-token base tier is snapshotted). Configs pinning a removed 2.x/1.5 + model should switch to a 3.x ID. +- **Anthropic: `claude-opus-4-7` → `claude-opus-4-8`.** Opus 4.8 is now the + default Anthropic model. Its built-in pricing ($5 input / $25 output / $0.50 + cache-read per 1M) and context window (1M tokens, up from the 200K the old + table recorded for 4.7) are updated to match Anthropic's current model docs. + Configs pinning `claude-opus-4-7` should switch to `claude-opus-4-8`. + ## [1.4.0] - 2026-05-29 ### Fixed diff --git a/README.md b/README.md index 3eb0104..9d3703c 100644 --- a/README.md +++ b/README.md @@ -260,9 +260,9 @@ Four API providers + two CLI-tool-backed providers ship in the box: | Provider | Models | Notes | |----------|--------|-------| -| **Anthropic** | Claude Opus 4.7, Sonnet 4.6, Haiku 4.5 | Ephemeral prompt caching (5 m TTL) cuts repeated input cost ~10×. | -| **OpenAI** | GPT-4o, GPT-4o-mini | Automatic prompt caching at ≥1024-token prefixes. | -| **Google Gemini** | Gemini 2.5 Pro (2 M context!), 2.5 Flash, 1.5 Flash | Largest free-tier context windows. | +| **Anthropic** | Claude Opus 4.8 (default), Sonnet 4.6, Haiku 4.5 | Ephemeral prompt caching (5 m TTL) cuts repeated input cost ~10×. Opus 4.8 advertises a 1 M-token context. | +| **OpenAI** | GPT-5.4-mini (default), GPT-5.5, GPT-5.5-pro, GPT-4o, GPT-4o-mini | Automatic prompt caching at ≥1024-token prefixes. `gpt-5.5-pro` runs via the Responses API (not Chat Completions) and can take several minutes per review. | +| **Google Gemini** | Gemini 3.5 Flash (default), 3.1 Pro, 3.1 Flash-Lite | ~1 M-token context windows. `gemini-3.1-pro-preview` is a preview model. | | **DeepSeek** | deepseek-chat, deepseek-reasoner | OpenAI-compatible API (`DEEPSEEK_API_KEY`); JSON is prompt-driven (degrades gracefully). | | **Mistral** | Mistral Large / Small, Codestral | OpenAI-compatible API (`MISTRAL_API_KEY`). | | **Cohere** | Command R+ / R, Command A | Cohere's OpenAI-compatibility endpoint (`COHERE_API_KEY`). | @@ -361,7 +361,7 @@ Two-tier YAML config with field-level merge: Plus environment variables for credentials and runtime tweaks, and CLI flags for one-off overrides (`--provider gemini --model -gemini-2.5-flash`). +gemini-3.5-flash`). | Variable | Effect | |---|---| @@ -379,13 +379,13 @@ version: 1 provider: anthropic # default provider providers: anthropic: - model: claude-opus-4-7 + model: claude-opus-4-8 pricing: # optional: override built-in $/1M rates - claude-opus-4-7: # (cost preflight / verbose footer / cache) - input_per_1m: 15.0 - output_per_1m: 75.0 # omitted fields keep the built-in value + claude-opus-4-8: # (cost preflight / verbose footer / cache) + input_per_1m: 5.0 + output_per_1m: 25.0 # omitted fields keep the built-in value openai: - model: gpt-4o + model: gpt-5.4-mini ollama: model: qwen2.5-coder:14b base_url: http://localhost:11434 diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 5c229b5..66be301 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,9 +7,9 @@ func Default() *Config { Version: CurrentSchemaVersion, Provider: "anthropic", Providers: map[string]ProviderConfig{ - "anthropic": {Model: "claude-opus-4-7", BaseURL: "https://api.anthropic.com"}, - "openai": {Model: "gpt-4o", BaseURL: "https://api.openai.com/v1"}, - "gemini": {Model: "gemini-2.5-pro"}, + "anthropic": {Model: "claude-opus-4-8", BaseURL: "https://api.anthropic.com"}, + "openai": {Model: "gpt-5.4-mini", BaseURL: "https://api.openai.com/v1"}, + "gemini": {Model: "gemini-3.5-flash"}, "ollama": {Model: "qwen2.5-coder:14b", BaseURL: "http://localhost:11434"}, }, Output: OutputConfig{ diff --git a/internal/i18n/messages.en.yml b/internal/i18n/messages.en.yml index 4ec3bfe..9dc128b 100644 --- a/internal/i18n/messages.en.yml +++ b/internal/i18n/messages.en.yml @@ -3,6 +3,8 @@ setup.provider.prompt: "Which provider would you like to configure?" setup.provider.help: "Pick the LLM provider you have an API key for." setup.api_key.prompt: "Enter your API key:" setup.api_key.empty: "API key cannot be empty." +setup.api_key.prompt_keep: "Enter a new API key (leave blank to keep the existing one):" +setup.api_key.help_keep: "A key is already configured for this provider. Leave blank to keep it, or enter a new one to replace it." setup.base_url.prompt: "Ollama base URL:" setup.model.prompt: "Pick a model:" setup.model.discover_failed: "Model name (could not discover from Ollama):" diff --git a/internal/i18n/messages.tr.yml b/internal/i18n/messages.tr.yml index 444a586..4428e4b 100644 --- a/internal/i18n/messages.tr.yml +++ b/internal/i18n/messages.tr.yml @@ -3,6 +3,8 @@ setup.provider.prompt: "Hangi sağlayıcıyı yapılandırmak istersiniz?" setup.provider.help: "API anahtarınız olan LLM sağlayıcısını seçin." setup.api_key.prompt: "API anahtarınızı girin:" setup.api_key.empty: "API anahtarı boş olamaz." +setup.api_key.prompt_keep: "Yeni bir API anahtarı girin (mevcut anahtarı korumak için boş bırakın):" +setup.api_key.help_keep: "Bu sağlayıcı için zaten bir anahtar yapılandırılmış. Korumak için boş bırakın ya da değiştirmek için yenisini girin." setup.base_url.prompt: "Ollama temel URL'si:" setup.model.prompt: "Bir model seçin:" setup.model.discover_failed: "Model adı (Ollama'dan keşfedilemedi):" diff --git a/internal/provider/anthropic/anthropic_test.go b/internal/provider/anthropic/anthropic_test.go index ff5a4b9..f587e0f 100644 --- a/internal/provider/anthropic/anthropic_test.go +++ b/internal/provider/anthropic/anthropic_test.go @@ -21,7 +21,7 @@ func TestModelsList(t *testing.T) { t.Errorf("Models() length = %d, want 3", len(got)) } want := map[string]bool{ - ModelOpus47: true, + ModelOpus48: true, ModelSonnet46: true, ModelHaiku45: true, } @@ -41,8 +41,8 @@ func TestModelsDefensiveCopy(t *testing.T) { } func TestIsModelSupported(t *testing.T) { - if !IsModelSupported(ModelOpus47) { - t.Error("Opus 4.7 should be supported") + if !IsModelSupported(ModelOpus48) { + t.Error("Opus 4.8 should be supported") } if IsModelSupported("gpt-4o") { t.Error("OpenAI model should not be reported as supported") @@ -53,8 +53,8 @@ func TestIsModelSupported(t *testing.T) { } func TestContextWindow(t *testing.T) { - if contextWindowFor(ModelOpus47) != 200_000 { - t.Error("Opus 4.7 context window wrong") + if contextWindowFor(ModelOpus48) != 1_000_000 { + t.Error("Opus 4.8 should advertise the 1M window") } if contextWindowFor(ModelSonnet46) != 1_000_000 { t.Error("Sonnet 4.6 should advertise the 1M window") @@ -65,7 +65,7 @@ func TestContextWindow(t *testing.T) { } func TestPricingLookup(t *testing.T) { - p := pricingFor(ModelOpus47) + p := pricingFor(ModelOpus48) if p.InputPer1M == 0 || p.OutputPer1M == 0 { t.Errorf("Opus pricing missing: %+v", p) } @@ -223,7 +223,7 @@ func messagePayload(content []map[string]any, inputTokens, outputTokens int) map "id": "msg_test", "type": "message", "role": "assistant", - "model": ModelOpus47, + "model": ModelOpus48, "content": content, "stop_reason": "end_turn", "stop_sequence": nil, @@ -253,7 +253,7 @@ func TestReviewWithFakeServerDegradesToText(t *testing.T) { t.Fatal(err) } resp, err := c.Review(context.Background(), provider.Request{ - Model: ModelOpus47, + Model: ModelOpus48, SystemPrompt: "rules", UserPrompt: "diff", MaxTokens: 256, @@ -291,7 +291,7 @@ func TestReviewWithToolUseFakeServer(t *testing.T) { t.Fatal(err) } resp, err := c.Review(context.Background(), provider.Request{ - Model: ModelOpus47, + Model: ModelOpus48, SystemPrompt: "rules", UserPrompt: "diff", MaxTokens: 256, diff --git a/internal/provider/anthropic/context_window.go b/internal/provider/anthropic/context_window.go index 5f7798f..c5b6155 100644 --- a/internal/provider/anthropic/context_window.go +++ b/internal/provider/anthropic/context_window.go @@ -5,7 +5,7 @@ package anthropic const defaultContextWindow = 200_000 var contextWindows = map[string]int{ - ModelOpus47: 200_000, + ModelOpus48: 1_000_000, ModelSonnet46: 1_000_000, ModelHaiku45: 200_000, } diff --git a/internal/provider/anthropic/models.go b/internal/provider/anthropic/models.go index cbf30f1..a7c6848 100644 --- a/internal/provider/anthropic/models.go +++ b/internal/provider/anthropic/models.go @@ -5,15 +5,15 @@ package anthropic const ( Name = "anthropic" - ModelOpus47 = "claude-opus-4-7" + ModelOpus48 = "claude-opus-4-8" ModelSonnet46 = "claude-sonnet-4-6" ModelHaiku45 = "claude-haiku-4-5-20251001" - DefaultModel = ModelOpus47 + DefaultModel = ModelOpus48 ) var supportedModels = []string{ - ModelOpus47, + ModelOpus48, ModelSonnet46, ModelHaiku45, } diff --git a/internal/provider/anthropic/pricing.go b/internal/provider/anthropic/pricing.go index 9b7cdcd..8802813 100644 --- a/internal/provider/anthropic/pricing.go +++ b/internal/provider/anthropic/pricing.go @@ -9,10 +9,10 @@ import "github.com/CommitBrief/commitbrief/internal/provider" // should be refreshed when a model price changes. Cached input is the // "prompt cache read" rate per Anthropic's ephemeral cache discount. var pricingTable = map[string]provider.Pricing{ - ModelOpus47: { - InputPer1M: 15.00, - OutputPer1M: 75.00, - CachedInputPer1M: 1.50, + ModelOpus48: { + InputPer1M: 5.00, + OutputPer1M: 25.00, + CachedInputPer1M: 0.50, }, ModelSonnet46: { InputPer1M: 3.00, diff --git a/internal/provider/gemini/context_window.go b/internal/provider/gemini/context_window.go index e89a3b9..7c6e0f9 100644 --- a/internal/provider/gemini/context_window.go +++ b/internal/provider/gemini/context_window.go @@ -4,10 +4,14 @@ package gemini const defaultContextWindow = 1_000_000 +// Gemini 3.x family baseline input window is 1M tokens. Exact published +// limits for these preview models were not yet broken out in the model +// docs at integration time; 1M is the documented floor and the safe value +// for the over-context guard. Bump if Google publishes larger windows. var contextWindows = map[string]int{ - ModelPro2_5: 2_000_000, - ModelFlash2_5: 1_000_000, - ModelFlash1_5: 1_000_000, + ModelPro31: 1_000_000, + ModelFlash35: 1_000_000, + ModelFlashLite31: 1_000_000, } func contextWindowFor(model string) int { diff --git a/internal/provider/gemini/gemini_test.go b/internal/provider/gemini/gemini_test.go index eac9379..9709356 100644 --- a/internal/provider/gemini/gemini_test.go +++ b/internal/provider/gemini/gemini_test.go @@ -32,8 +32,8 @@ func TestModelsDefensiveCopy(t *testing.T) { } func TestIsModelSupported(t *testing.T) { - if !IsModelSupported(ModelPro2_5) { - t.Error("gemini-2.5-pro should be supported") + if !IsModelSupported(ModelPro31) { + t.Error("gemini-3.1-pro-preview should be supported") } if IsModelSupported("gpt-4o") { t.Error("OpenAI model should not be supported here") @@ -41,8 +41,8 @@ func TestIsModelSupported(t *testing.T) { } func TestContextWindow(t *testing.T) { - if contextWindowFor(ModelPro2_5) != 2_000_000 { - t.Errorf("pro 2.5 context window wrong: %d", contextWindowFor(ModelPro2_5)) + if contextWindowFor(ModelPro31) != 1_000_000 { + t.Errorf("pro 3.1 context window wrong: %d", contextWindowFor(ModelPro31)) } if contextWindowFor("unknown") != defaultContextWindow { t.Error("unknown model should fall back to defaultContextWindow") @@ -50,7 +50,7 @@ func TestContextWindow(t *testing.T) { } func TestPricingLookup(t *testing.T) { - p := pricingFor(ModelPro2_5) + p := pricingFor(ModelPro31) if p.InputPer1M == 0 || p.OutputPer1M == 0 { t.Errorf("pro pricing missing: %+v", p) } @@ -138,7 +138,7 @@ func TestReviewWithFakeServer(t *testing.T) { t.Fatal(err) } resp, err := c.Review(context.Background(), provider.Request{ - Model: ModelPro2_5, + Model: ModelPro31, SystemPrompt: "rules", UserPrompt: "diff", MaxTokens: 256, diff --git a/internal/provider/gemini/models.go b/internal/provider/gemini/models.go index 199cf1c..3aa48f8 100644 --- a/internal/provider/gemini/models.go +++ b/internal/provider/gemini/models.go @@ -5,17 +5,20 @@ package gemini const ( Name = "gemini" - ModelPro2_5 = "gemini-2.5-pro" - ModelFlash2_5 = "gemini-2.5-flash" - ModelFlash1_5 = "gemini-1.5-flash" + // ModelPro31 is a preview model: the public pricing page lists the Pro + // tier only as gemini-3.1-pro-preview. Update to the stable ID once + // Google promotes it out of preview. + ModelPro31 = "gemini-3.1-pro-preview" + ModelFlash35 = "gemini-3.5-flash" + ModelFlashLite31 = "gemini-3.1-flash-lite" - DefaultModel = ModelPro2_5 + DefaultModel = ModelFlash35 ) var supportedModels = []string{ - ModelPro2_5, - ModelFlash2_5, - ModelFlash1_5, + ModelPro31, + ModelFlash35, + ModelFlashLite31, } func Models() []string { diff --git a/internal/provider/gemini/pricing.go b/internal/provider/gemini/pricing.go index 89427ac..6b2d048 100644 --- a/internal/provider/gemini/pricing.go +++ b/internal/provider/gemini/pricing.go @@ -4,26 +4,28 @@ package gemini import "github.com/CommitBrief/commitbrief/internal/provider" -// Gemini per-1M-token pricing snapshot (paid tier). +// Gemini per-1M-token pricing snapshot (paid "Standard" tier). // Source: https://ai.google.dev/gemini-api/docs/pricing -// CachedInputPer1M reflects Gemini's context-caching discount (separate -// API; we track the per-token rate so cost reporting is accurate when a -// cache is wired up in a future phase). +// gemini-3.1-pro-preview has tiered pricing (≤200K vs >200K input tokens: +// $2/$12 vs $4/$18); we snapshot the ≤200K base, so the verbose cost footer +// may under-report on very large inputs. CachedInputPer1M reflects Gemini's +// implicit-cache read discount (~0.25× input); context caching is not wired +// into the client yet, so the rate is informational for now. var pricingTable = map[string]provider.Pricing{ - ModelPro2_5: { - InputPer1M: 1.25, - OutputPer1M: 10.00, - CachedInputPer1M: 0.31, + ModelPro31: { + InputPer1M: 2.00, + OutputPer1M: 12.00, + CachedInputPer1M: 0.50, }, - ModelFlash2_5: { - InputPer1M: 0.30, - OutputPer1M: 2.50, - CachedInputPer1M: 0.075, + ModelFlash35: { + InputPer1M: 1.50, + OutputPer1M: 9.00, + CachedInputPer1M: 0.375, }, - ModelFlash1_5: { - InputPer1M: 0.075, - OutputPer1M: 0.30, - CachedInputPer1M: 0.01875, + ModelFlashLite31: { + InputPer1M: 0.25, + OutputPer1M: 1.50, + CachedInputPer1M: 0.0625, }, } diff --git a/internal/provider/openai/client.go b/internal/provider/openai/client.go index 33b0a17..b312a7e 100644 --- a/internal/provider/openai/client.go +++ b/internal/provider/openai/client.go @@ -9,6 +9,7 @@ import ( sdk "github.com/openai/openai-go" "github.com/openai/openai-go/option" + "github.com/openai/openai-go/responses" "github.com/openai/openai-go/shared" "github.com/CommitBrief/commitbrief/internal/config" @@ -18,8 +19,12 @@ import ( const ( defaultMaxTokens = 4096 - testPingPrompt = "ping" - testPingMaxTok = 8 + // defaultReasoningMaxTokens is the output ceiling for GPT-5 reasoning + // models when the caller specifies none — larger than defaultMaxTokens + // because reasoning tokens are billed out of the same budget. + defaultReasoningMaxTokens = 16384 + testPingPrompt = "ping" + testPingMaxTok = 8 ) type Client struct { @@ -69,6 +74,13 @@ func (c *Client) Pricing(model string) provider.Pricing { } func (c *Client) Review(ctx context.Context, req provider.Request) (provider.Response, error) { + model := req.Model + if model == "" { + model = c.DefaultModel() + } + if usesResponsesAPI(model) { + return c.reviewViaResponses(ctx, req, model) + } params := c.buildParams(req) completion, err := c.sdk.Chat.Completions.New(ctx, params) if err != nil { @@ -81,9 +93,37 @@ func (c *Client) Review(ctx context.Context, req provider.Request) (provider.Res }, nil } +// reviewViaResponses drives a Responses-API-only model (gpt-5.5-pro). The +// call is synchronous and may take several minutes for the pro model; it +// honours the caller's context deadline (the SDK sets no client timeout of +// its own). +func (c *Client) reviewViaResponses(ctx context.Context, req provider.Request, model string) (provider.Response, error) { + resp, err := c.sdk.Responses.New(ctx, c.buildResponsesParams(req, model)) + if err != nil { + return provider.Response{}, mapError(err) + } + return provider.Response{ + Content: resp.OutputText(), + Model: resp.Model, + Usage: mapResponsesUsage(resp.Usage), + }, nil +} + func (c *Client) TestConnection(ctx context.Context) error { + model := c.DefaultModel() + if usesResponsesAPI(model) { + params := responses.ResponseNewParams{ + Model: shared.ResponsesModel(model), + MaxOutputTokens: sdk.Int(testPingMaxTok), + Input: responses.ResponseNewParamsInputUnion{OfString: sdk.String(testPingPrompt)}, + } + if _, err := c.sdk.Responses.New(ctx, params); err != nil { + return mapError(err) + } + return nil + } params := sdk.ChatCompletionNewParams{ - Model: shared.ChatModel(c.DefaultModel()), + Model: shared.ChatModel(model), MaxCompletionTokens: sdk.Int(testPingMaxTok), Messages: []sdk.ChatCompletionMessageParamUnion{ sdk.UserMessage(testPingPrompt), @@ -102,7 +142,7 @@ func (c *Client) buildParams(req provider.Request) sdk.ChatCompletionNewParams { } maxTokens := int64(req.MaxTokens) if maxTokens <= 0 { - maxTokens = defaultMaxTokens + maxTokens = defaultMaxTokensFor(model) } messages := make([]sdk.ChatCompletionMessageParamUnion, 0, 2) @@ -124,6 +164,28 @@ func (c *Client) buildParams(req provider.Request) sdk.ChatCompletionNewParams { return params } +func (c *Client) buildResponsesParams(req provider.Request, model string) responses.ResponseNewParams { + maxTokens := int64(req.MaxTokens) + if maxTokens <= 0 { + maxTokens = defaultMaxTokensFor(model) + } + + params := responses.ResponseNewParams{ + Model: shared.ResponsesModel(model), + MaxOutputTokens: sdk.Int(maxTokens), + Input: responses.ResponseNewParamsInputUnion{OfString: sdk.String(req.UserPrompt)}, + } + if req.SystemPrompt != "" { + params.Instructions = sdk.String(req.SystemPrompt) + } + // Structured-findings JSON contract (ADR-0014). Omitted for FreeForm + // (ADR-0015) so the model returns a plain-text completion. + if !req.FreeForm { + params.Text = buildResponsesTextFormat() + } + return params +} + func extractText(c *sdk.ChatCompletion) string { if c == nil || len(c.Choices) == 0 { return "" @@ -139,6 +201,14 @@ func mapUsage(u sdk.CompletionUsage) provider.Usage { } } +func mapResponsesUsage(u responses.ResponseUsage) provider.Usage { + return provider.Usage{ + InputTokens: int(u.InputTokens), + OutputTokens: int(u.OutputTokens), + CachedInputTokens: int(u.InputTokensDetails.CachedTokens), + } +} + func mapError(err error) error { if err == nil { return nil diff --git a/internal/provider/openai/context_window.go b/internal/provider/openai/context_window.go index e3781c4..41861c9 100644 --- a/internal/provider/openai/context_window.go +++ b/internal/provider/openai/context_window.go @@ -7,6 +7,9 @@ const defaultContextWindow = 128_000 var contextWindows = map[string]int{ ModelGPT4o: 128_000, ModelGPT4oMini: 128_000, + ModelGPT55: 1_050_000, + ModelGPT54Mini: 400_000, + ModelGPT55Pro: 1_050_000, } func contextWindowFor(model string) int { diff --git a/internal/provider/openai/models.go b/internal/provider/openai/models.go index d9509f9..569fc6c 100644 --- a/internal/provider/openai/models.go +++ b/internal/provider/openai/models.go @@ -7,15 +7,53 @@ const ( ModelGPT4o = "gpt-4o" ModelGPT4oMini = "gpt-4o-mini" + ModelGPT55 = "gpt-5.5" + ModelGPT54Mini = "gpt-5.4-mini" + ModelGPT55Pro = "gpt-5.5-pro" - DefaultModel = ModelGPT4o + DefaultModel = ModelGPT54Mini ) var supportedModels = []string{ + ModelGPT54Mini, + ModelGPT55, + ModelGPT55Pro, ModelGPT4o, ModelGPT4oMini, } +// responsesAPIModels are served only through the Responses API rather than +// Chat Completions (gpt-5.5-pro operates exclusively there per OpenAI's +// model docs). Review/TestConnection route these calls differently. +var responsesAPIModels = map[string]bool{ + ModelGPT55Pro: true, +} + +// usesResponsesAPI reports whether the model must be driven through the +// Responses API instead of Chat Completions. +func usesResponsesAPI(model string) bool { + return responsesAPIModels[model] +} + +// reasoningModels are the GPT-5 family models that spend reasoning tokens +// out of the output-token budget. They need a larger default ceiling than +// the gpt-4o-era 4096 so a findings JSON isn't truncated by reasoning. +var reasoningModels = map[string]bool{ + ModelGPT55: true, + ModelGPT54Mini: true, + ModelGPT55Pro: true, +} + +// defaultMaxTokensFor returns the output-token ceiling to use when the +// caller did not specify one. Reasoning models get a higher budget; +// everything else keeps the historical gpt-4o default. +func defaultMaxTokensFor(model string) int64 { + if reasoningModels[model] { + return defaultReasoningMaxTokens + } + return defaultMaxTokens +} + func Models() []string { out := make([]string, len(supportedModels)) copy(out, supportedModels) diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 7bff9cd..0cda7c5 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -17,10 +17,16 @@ import ( func TestModelsList(t *testing.T) { got := Models() - if len(got) != 2 { - t.Errorf("Models() length = %d, want 2", len(got)) + want := map[string]bool{ + ModelGPT4o: true, + ModelGPT4oMini: true, + ModelGPT55: true, + ModelGPT54Mini: true, + ModelGPT55Pro: true, + } + if len(got) != len(want) { + t.Errorf("Models() length = %d, want %d", len(got), len(want)) } - want := map[string]bool{ModelGPT4o: true, ModelGPT4oMini: true} for _, m := range got { if !want[m] { t.Errorf("unexpected model %q", m) diff --git a/internal/provider/openai/pricing.go b/internal/provider/openai/pricing.go index eac4817..52873b3 100644 --- a/internal/provider/openai/pricing.go +++ b/internal/provider/openai/pricing.go @@ -20,6 +20,26 @@ var pricingTable = map[string]provider.Pricing{ OutputPer1M: 0.60, CachedInputPer1M: 0.075, }, + // gpt-5.5 has tiered pricing: prompts above 272K input tokens incur 2x + // input / 1.5x output for the whole session. We snapshot the base + // (<=272K) tier; review diffs rarely exceed it, so the verbose cost + // footer may under-report on very large inputs. + ModelGPT55: { + InputPer1M: 5.00, + OutputPer1M: 30.00, + CachedInputPer1M: 0.50, + }, + ModelGPT54Mini: { + InputPer1M: 0.75, + OutputPer1M: 4.50, + CachedInputPer1M: 0.075, + }, + // gpt-5.5-pro does not offer a cached-input discount. + ModelGPT55Pro: { + InputPer1M: 30.00, + OutputPer1M: 180.00, + CachedInputPer1M: 0, + }, } func pricingFor(model string) provider.Pricing { diff --git a/internal/provider/openai/structured.go b/internal/provider/openai/structured.go index 47ac9ac..87a22e1 100644 --- a/internal/provider/openai/structured.go +++ b/internal/provider/openai/structured.go @@ -4,6 +4,7 @@ package openai import ( sdk "github.com/openai/openai-go" + "github.com/openai/openai-go/responses" "github.com/openai/openai-go/shared" ) @@ -81,3 +82,20 @@ func buildResponseFormat() sdk.ChatCompletionNewParamsResponseFormatUnion { }, } } + +// buildResponsesTextFormat returns the Responses API equivalent of +// buildResponseFormat — the same strict findings schema expressed as a +// `text.format` json_schema config. Used by Responses-API-only models +// (gpt-5.5-pro). +func buildResponsesTextFormat() responses.ResponseTextConfigParam { + return responses.ResponseTextConfigParam{ + Format: responses.ResponseFormatTextConfigUnionParam{ + OfJSONSchema: &responses.ResponseFormatTextJSONSchemaConfigParam{ + Name: schemaName, + Description: sdk.String("Structured findings for a code review."), + Strict: sdk.Bool(true), + Schema: responseSchema, + }, + }, + } +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 61175cf..26b7c72 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -122,6 +122,37 @@ func TestApplyPreservesExistingKeys(t *testing.T) { } } +// TestApplyKeepsKeyWhenReconfiguringSameProvider guards the wizard's +// "leave blank to keep the existing key" behavior: re-running setup for a +// provider that already has a key, submitting an empty key but a new model, +// must preserve the stored key and update only the model. This is the +// contract the promptAPIKey existing-key branch relies on. +func TestApplyKeepsKeyWhenReconfiguringSameProvider(t *testing.T) { + base := config.Default() + base.Providers["anthropic"] = config.ProviderConfig{ + APIKey: "sk-ant-existing", + Model: "claude-opus-4-8", + BaseURL: "https://api.anthropic.com", + } + base.Provider = "anthropic" + + // Empty APIKey (user left the prompt blank), new model only. + cfg := Apply(base, Choices{ + Provider: "anthropic", + Model: "claude-sonnet-4-6", + }) + + if got := cfg.Providers["anthropic"].APIKey; got != "sk-ant-existing" { + t.Errorf("APIKey not preserved on empty input: got %q, want sk-ant-existing", got) + } + if got := cfg.Providers["anthropic"].Model; got != "claude-sonnet-4-6" { + t.Errorf("Model not updated: got %q, want claude-sonnet-4-6", got) + } + if got := cfg.Providers["anthropic"].BaseURL; got != "https://api.anthropic.com" { + t.Errorf("BaseURL lost: got %q", got) + } +} + func TestApplyFirstRunStartsFromDefault(t *testing.T) { // Passing nil base mimics first-time setup: result must be a clean // Default config with the choices layered on, no leftover state. diff --git a/internal/setup/wizard.go b/internal/setup/wizard.go index 065e9b6..7ee280f 100644 --- a/internal/setup/wizard.go +++ b/internal/setup/wizard.go @@ -44,21 +44,21 @@ var DefaultSpecs = []ProviderSpec{ Name: "anthropic", Label: "Anthropic (Claude)", NeedsKey: true, - Models: []string{"claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"}, + Models: []string{"claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"}, APIKeyHelp: "Get an API key from https://console.anthropic.com/", }, { Name: "openai", Label: "OpenAI (GPT)", NeedsKey: true, - Models: []string{"gpt-4o", "gpt-4o-mini"}, + Models: []string{"gpt-5.4-mini", "gpt-5.5", "gpt-5.5-pro", "gpt-4o", "gpt-4o-mini"}, APIKeyHelp: "Get an API key from https://platform.openai.com/", }, { Name: "gemini", Label: "Google Gemini", NeedsKey: true, - Models: []string{"gemini-2.5-pro", "gemini-2.5-flash", "gemini-1.5-flash"}, + Models: []string{"gemini-3.5-flash", "gemini-3.1-pro-preview", "gemini-3.1-flash-lite"}, APIKeyHelp: "Get an API key from https://aistudio.google.com/", }, { @@ -198,7 +198,12 @@ func Run(ctx context.Context, opts RunOptions) (*config.Config, error) { } if spec.NeedsKey { - if err := promptAPIKey(ctx, spec, &choices, opts.Catalog); err != nil { + // If the target config already holds a key for the chosen provider, + // let the user leave the prompt blank to keep it — switching the + // active provider/model shouldn't force a key re-entry. Apply() + // preserves the existing key on empty input. + hasExistingKey := base != nil && base.Providers[choices.Provider].APIKey != "" + if err := promptAPIKey(ctx, spec, &choices, opts.Catalog, hasExistingKey); err != nil { return nil, err } } @@ -261,15 +266,23 @@ func selectProvider(ctx context.Context, specs []ProviderSpec, choices *Choices, return form.RunWithContext(ctx) } -func promptAPIKey(ctx context.Context, spec *ProviderSpec, choices *Choices, cat *i18n.Catalog) error { - form := huh.NewForm(huh.NewGroup( - huh.NewInput(). +func promptAPIKey(ctx context.Context, spec *ProviderSpec, choices *Choices, cat *i18n.Catalog, hasExistingKey bool) error { + input := huh.NewInput(). + EchoMode(huh.EchoModePassword). + Value(&choices.APIKey) + if hasExistingKey { + // A key already exists: allow an empty submission (Apply keeps the + // stored key) so the user can change only the provider/model. + input = input. + Title(tr(cat, "setup.api_key.prompt_keep", "Enter a new API key (leave blank to keep the existing one):")). + Description(tr(cat, "setup.api_key.help_keep", "A key is already configured for this provider. Leave blank to keep it, or enter a new one to replace it.")) + } else { + input = input. Title(tr(cat, "setup.api_key.prompt", "Enter your API key:")). Description(spec.APIKeyHelp). - EchoMode(huh.EchoModePassword). - Validate(notEmptyFor(cat)). - Value(&choices.APIKey), - )) + Validate(notEmptyFor(cat)) + } + form := huh.NewForm(huh.NewGroup(input)) return form.RunWithContext(ctx) }