Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions backend/internal/pkg/apicompat/anthropic_responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1683,6 +1683,29 @@ func TestAnthropicToResponses_TemperatureStrippedForAllGpt5Variants(t *testing.T
}
}

func TestAnthropicToResponses_TemperaturePreservedForGPT56(t *testing.T) {
temp := 0.35
topP := 0.75
for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} {
t.Run(model, func(t *testing.T) {
req := &AnthropicRequest{
Model: model,
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Temperature: &temp,
TopP: &topP,
}

resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Temperature)
assert.InDelta(t, temp, *resp.Temperature, 1e-9)
require.NotNil(t, resp.TopP)
assert.InDelta(t, topP, *resp.TopP, 1e-9)
})
}
}

// ---------------------------------------------------------------------------
// AnthropicToResponsesResponse: Anthropic input_tokens excludes cached tokens
// while OpenAI Responses input_tokens is the total including cached tokens.
Expand Down
23 changes: 12 additions & 11 deletions backend/internal/pkg/apicompat/anthropic_to_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
Include: []string{"reasoning.encrypted_content"},
}

// Reasoning models (gpt-5.x) served via the Responses API do not accept
// sampling parameters. Sending temperature or top_p causes a 400
// "Unsupported parameter" error, so we only forward them for non-reasoning
// models.
if !isReasoningModel(req.Model) {
if !rejectsSamplingParameters(req.Model) {
out.Temperature = req.Temperature
out.TopP = req.TopP
}
Expand Down Expand Up @@ -465,12 +461,17 @@ func boolPtr(v bool) *bool {
return &v
}

// 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.
func isReasoningModel(model string) bool {
return strings.HasPrefix(model, "gpt-5")
// rejectsSamplingParameters reports GPT-5 families that reject temperature
// and top_p. GPT-5.6 exposes both sampling parameters through the public API.
func rejectsSamplingParameters(model string) bool {
normalized := strings.ToLower(strings.TrimSpace(model))
if slash := strings.LastIndex(normalized, "/"); slash >= 0 {
normalized = normalized[slash+1:]
}
normalized = strings.ReplaceAll(normalized, "_", "-")
return strings.HasPrefix(normalized, "gpt-5") &&
normalized != "gpt-5.6" &&
!strings.HasPrefix(normalized, "gpt-5.6-")
}

// normalizeToolParameters ensures the tool parameter schema is valid for
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,7 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR
Stream: req.Stream,
}

// Sampling params: reasoning models (gpt-5.x) reject temperature/top_p.
if !isReasoningModel(req.Model) {
if !rejectsSamplingParameters(req.Model) {
out.Temperature = req.Temperature
out.TopP = req.TopP
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,25 @@ func TestAnthropicToChatCompletionsRequest_TemperaturePreservedForNonReasoningMo
require.Equal(t, 0.9, *out.TopP)
}

func TestAnthropicToChatCompletionsRequest_TemperaturePreservedForGPT56(t *testing.T) {
temp := 0.35
topP := 0.75
req := &AnthropicRequest{
Model: "gpt-5.6-terra",
MaxTokens: 100,
Temperature: &temp,
TopP: &topP,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
}

out, err := AnthropicToChatCompletionsRequest(req)
require.NoError(t, err)
require.NotNil(t, out.Temperature)
require.InDelta(t, temp, *out.Temperature, 1e-9)
require.NotNil(t, out.TopP)
require.InDelta(t, topP, *out.TopP, 1e-9)
}

func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) {
req := &AnthropicRequest{
Model: "claude-sonnet-4-20250514",
Expand Down
22 changes: 22 additions & 0 deletions backend/internal/pkg/apicompat/chatcompletions_responses_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,28 @@ func TestChatCompletionsToResponses_TemperaturePreservedForNonReasoningModel(t *
assert.InDelta(t, 0.7, *resp.TopP, 1e-9)
}

func TestChatCompletionsToResponses_TemperaturePreservedForGPT56(t *testing.T) {
temp := 0.35
topP := 0.75
for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} {
t.Run(model, func(t *testing.T) {
req := &ChatCompletionsRequest{
Model: model,
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
Temperature: &temp,
TopP: &topP,
}

resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Temperature)
assert.InDelta(t, temp, *resp.Temperature, 1e-9)
require.NotNil(t, resp.TopP)
assert.InDelta(t, topP, *resp.TopP, 1e-9)
})
}
}

func TestChatCompletionsToResponses_AssistantWithTextAndToolCalls(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest,
ParallelToolCalls: req.ParallelToolCalls,
}

// Reasoning models (gpt-5.x) do not accept sampling parameters.
// See isReasoningModel in anthropic_to_responses.go.
if !isReasoningModel(req.Model) {
if !rejectsSamplingParameters(req.Model) {
out.Temperature = req.Temperature
out.TopP = req.TopP
}
Expand Down
189 changes: 189 additions & 0 deletions backend/internal/service/account_temperature_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package service

import (
"context"
"encoding/json"
"math"
"strings"

infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)

type accountTemperatureMode string
type temperaturePath string

const (
accountTemperatureModeInherit accountTemperatureMode = "inherit"
accountTemperatureModeOverride accountTemperatureMode = "override"
accountTemperatureModeOmit accountTemperatureMode = "omit"

temperaturePathTopLevel temperaturePath = "temperature"
temperaturePathGemini temperaturePath = "generationConfig.temperature"
)

type accountTemperaturePolicy struct {
mode accountTemperatureMode
temperature float64
}

func validateAccountTemperatureCredentials(credentials map[string]any) error {
_, err := parseAccountTemperaturePolicy(credentials)
return err
}

func hasAccountTemperatureCredentialUpdate(credentials map[string]any) bool {
if credentials == nil {
return false
}
_, hasMode := credentials["temperature_mode"]
_, hasValue := credentials["temperature"]
return hasMode || hasValue
}

func parseAccountTemperaturePolicy(credentials map[string]any) (accountTemperaturePolicy, error) {
policy := accountTemperaturePolicy{mode: accountTemperatureModeInherit}
if credentials == nil {
return policy, nil
}

if rawMode, ok := credentials["temperature_mode"]; ok {
mode, valid := rawMode.(string)
if !valid {
return policy, invalidAccountTemperature("temperature_mode must be inherit, override, or omit")
}
policy.mode = accountTemperatureMode(strings.TrimSpace(mode))
}

switch policy.mode {
case accountTemperatureModeInherit, accountTemperatureModeOverride, accountTemperatureModeOmit:
default:
return policy, invalidAccountTemperature("temperature_mode must be inherit, override, or omit")
}

rawTemperature, hasTemperature := credentials["temperature"]
if !hasTemperature || rawTemperature == nil {
if policy.mode == accountTemperatureModeOverride {
return policy, invalidAccountTemperature("temperature is required when temperature_mode is override")
}
return policy, nil
}

temperature, valid := accountTemperatureNumber(rawTemperature)
if !valid {
return policy, invalidAccountTemperature("temperature must be a finite number")
}
policy.temperature = temperature
return policy, nil
}

func applyAccountTemperaturePolicy(body []byte, account *Account, path temperaturePath) ([]byte, error) {
if !gjson.ValidBytes(body) {
return nil, infraerrors.BadRequest("INVALID_REQUEST_BODY", "request body must be valid JSON")
}

var credentials map[string]any
if account != nil {
credentials = account.Credentials
}
policy, err := parseAccountTemperaturePolicy(credentials)
if err != nil {
return nil, err
}

field := gjson.GetBytes(body, string(path))
if field.Exists() && field.Type != gjson.Number && field.Type != gjson.Null {
return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature must be a number or null")
}
if field.Type == gjson.Number {
value := field.Float()
if math.IsNaN(value) || math.IsInf(value, 0) {
return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature must be a finite number")
}
}

normalized := body
if field.Exists() && field.Type == gjson.Null {
normalized, err = sjson.DeleteBytes(normalized, string(path))
if err != nil {
return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature could not be removed from request body")
}
}

switch policy.mode {
case accountTemperatureModeInherit:
return normalized, nil
case accountTemperatureModeOmit:
updated, deleteErr := sjson.DeleteBytes(normalized, string(path))
if deleteErr != nil {
return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature could not be removed from request body")
}
return updated, nil
case accountTemperatureModeOverride:
updated, setErr := sjson.SetBytes(normalized, string(path), policy.temperature)
if setErr != nil {
return nil, infraerrors.BadRequest("INVALID_TEMPERATURE", "temperature could not be written to request body")
}
return updated, nil
default:
return nil, invalidAccountTemperature("temperature_mode must be inherit, override, or omit")
}
}

func applyResolvedAccountTemperaturePolicy(
ctx context.Context,
repo AccountRepository,
account *Account,
body []byte,
path temperaturePath,
) ([]byte, error) {
credentialAccount, err := resolveCredentialAccount(ctx, repo, account)
if err != nil {
return nil, err
}
return applyAccountTemperaturePolicy(body, credentialAccount, path)
}

func accountTemperatureNumber(value any) (float64, bool) {
var number float64
switch v := value.(type) {
case json.Number:
parsed, err := v.Float64()
if err != nil {
return 0, false
}
number = parsed
case float64:
number = v
case float32:
number = float64(v)
case int:
number = float64(v)
case int8:
number = float64(v)
case int16:
number = float64(v)
case int32:
number = float64(v)
case int64:
number = float64(v)
case uint:
number = float64(v)
case uint8:
number = float64(v)
case uint16:
number = float64(v)
case uint32:
number = float64(v)
case uint64:
number = float64(v)
default:
return 0, false
}
return number, !math.IsNaN(number) && !math.IsInf(number, 0)
}

func invalidAccountTemperature(message string) error {
return infraerrors.BadRequest("INVALID_ACCOUNT_TEMPERATURE", message)
}
Loading
Loading