diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 23acc0bea8..008121e0f9 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -469,10 +469,38 @@ func boolPtr(v bool) *bool { // isReasoningModel reports whether model is a reasoning model that does not // support sampling parameters (temperature, top_p) via the Responses API. -// All gpt-5.x models are reasoning-only; the Responses API returns -// "Unsupported parameter: temperature" if these fields are present. +// GPT-5 and every later generation are reasoning-only; the Responses API +// returns "Unsupported parameter: temperature" if these fields are present. +// +// Keyed on the generation number instead of a "gpt-5" prefix: pinning the +// prefix meant each new family (gpt-6-astra and whatever follows) silently +// fell through to the sampling branch and failed upstream on every compat +// request until someone edited this line. func isReasoningModel(model string) bool { - return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model) + major, ok := openAIModelGeneration(model) + return (ok && major >= 5) || openai.IsGPT6SolOrLunaModelSpelling(model) +} + +// openAIModelGeneration extracts N from a "gpt-N[.M][-suffix]" model id. +// ok is false for non-GPT ids and for GPT families that carry no numeric +// generation (gpt-image-1, gpt-audio, ...). +func openAIModelGeneration(model string) (int, bool) { + rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-") + if !ok { + return 0, false + } + major, digits := 0, 0 + for _, r := range rest { + if r < '0' || r > '9' { + break + } + major = major*10 + int(r-'0') + digits++ + } + if digits == 0 { + return 0, false + } + return major, true } // normalizeToolParameters ensures the tool parameter schema is valid for diff --git a/backend/internal/pkg/apicompat/reasoning_generation_test.go b/backend/internal/pkg/apicompat/reasoning_generation_test.go new file mode 100644 index 0000000000..8347be8fdd --- /dev/null +++ b/backend/internal/pkg/apicompat/reasoning_generation_test.go @@ -0,0 +1,78 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// GPT-5 and every later generation are reasoning-only: forwarding temperature or +// top_p makes the Responses API reject the request with "Unsupported parameter". +// The check is keyed on the generation number so a new family (gpt-6-astra and +// whatever follows) does not silently fall into the sampling branch. +func TestIsReasoningModelCoversLaterGenerations(t *testing.T) { + for _, tc := range []struct { + model string + want bool + }{ + {"gpt-6-astra", true}, + {"gpt-6", true}, + {"gpt-7-whatever", true}, + {"gpt-6-sol", true}, + {"gpt-5.5", true}, + {"gpt-5.2", true}, + {"gpt-5", true}, + {"GPT-6-Astra", true}, + {" gpt-6-astra ", true}, + {"gpt-4o", false}, + {"gpt-4.1", false}, + {"gpt-image-1", false}, + {"gpt-audio", false}, + {"claude-opus-4-6", false}, + {"gemini-3.1-pro", false}, + {"", false}, + } { + if got := isReasoningModel(tc.model); got != tc.want { + t.Errorf("isReasoningModel(%q) = %v, want %v", tc.model, got, tc.want) + } + } +} + +func TestOpenAIModelGeneration(t *testing.T) { + for _, tc := range []struct { + model string + wantMajor int + wantOK bool + }{ + {"gpt-6-astra", 6, true}, + {"gpt-5.5", 5, true}, + {"gpt-4o", 4, true}, + {"gpt-10-future", 10, true}, + {"gpt-image-1", 0, false}, + {"claude-opus-4-6", 0, false}, + {"", 0, false}, + } { + major, ok := openAIModelGeneration(tc.model) + if major != tc.wantMajor || ok != tc.wantOK { + t.Errorf("openAIModelGeneration(%q) = (%d, %v), want (%d, %v)", + tc.model, major, ok, tc.wantMajor, tc.wantOK) + } + } +} + +func TestAnthropicToResponses_TemperatureStrippedForGPT6Astra(t *testing.T) { + temp := 0.7 + req := &AnthropicRequest{ + Model: "gpt-6-astra", + MaxTokens: 1024, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Temperature: &temp, + TopP: &temp, + } + + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + require.Nil(t, resp.Temperature, "gpt-6-astra is reasoning-only: temperature must be stripped") + require.Nil(t, resp.TopP, "gpt-6-astra is reasoning-only: top_p must be stripped") +}