Merge pull request #4179 from scientist-blockchian/fix/messages-exact-model-mapping

fix(openai): preserve exact messages dispatch mappings
This commit is contained in:
Wesley Liddick
2026-07-14 10:13:27 +08:00
committed by GitHub
6 changed files with 153 additions and 82 deletions
@@ -415,11 +415,13 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
SonnetMappedModel: "gpt-5.2",
ExactModelMappings: map[string]string{
"claude-sonnet-4-5-20250929": "gpt-5.4-mini-high",
"claude-fable-5": "gpt-5.6-sol",
},
},
},
}
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-sonnet-4-5-20250929"))
require.Equal(t, "gpt-5.6-sol", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-fable-5"))
})
t.Run("uses_family_default_when_no_override", func(t *testing.T) {
@@ -124,6 +124,55 @@ func TestApplyOpenAICompatModelNormalization(t *testing.T) {
})
}
func TestForwardAsAnthropic_UsesExactFableMessagesDispatchModel(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"claude-fable-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
`data: {"type":"response.completed","response":{"id":"resp_fable","object":"response","model":"gpt-5.6-sol","status":"completed","output":[{"type":"message","id":"msg_fable","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_fable"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{
httpUpstream: upstream,
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
account := &Account{
ID: 1,
Name: "openai-oauth",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-acc",
},
}
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.6-sol")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "claude-fable-5", result.Model)
require.Equal(t, "gpt-5.6-sol", result.BillingModel)
require.Equal(t, "gpt-5.6-sol", result.UpstreamModel)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
require.NotContains(t, string(upstream.lastBody), "claude-fable-5")
require.Equal(t, "claude-fable-5", gjson.GetBytes(rec.Body.Bytes(), "model").String())
}
func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
@@ -98,7 +98,7 @@ func TestNormalizeResponsesBodyServiceTier(t *testing.T) {
require.False(t, gjson.GetBytes(body, "service_tier").Exists())
}
func TestForwardAsChatCompletions_UnknownModelDoesNotUseDefaultMappedModel(t *testing.T) {
func TestForwardAsChatCompletions_UnknownModelWithoutMessagesDispatchKeepsRequestedModel(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -129,7 +129,7 @@ func TestForwardAsChatCompletions_UnknownModelDoesNotUseDefaultMappedModel(t *te
},
}
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, "gpt6", gjson.GetBytes(upstream.lastBody, "model").String())
@@ -37,3 +37,25 @@ func TestGroupResolveMessagesDispatchModel_GrokMapsClaudeFamilyToGrok(t *testing
require.Empty(t, group.ResolveMessagesDispatchModel("grok"))
require.Empty(t, group.ResolveMessagesDispatchModel("gpt-5.3-codex"))
}
func TestSanitizeGroupMessagesDispatchFields_ClearsNonOpenAIPlatform(t *testing.T) {
t.Parallel()
group := &Group{
Platform: PlatformAnthropic,
AllowMessagesDispatch: true,
DefaultMappedModel: "gpt-5.6-sol",
MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
SonnetMappedModel: "gpt-5.3-codex",
ExactModelMappings: map[string]string{
"claude-fable-5": "gpt-5.6-sol",
},
},
}
sanitizeGroupMessagesDispatchFields(group)
require.False(t, group.AllowMessagesDispatch)
require.Empty(t, group.DefaultMappedModel)
require.Equal(t, OpenAIMessagesDispatchModelConfig{}, group.MessagesDispatchModelConfig)
}
@@ -3,19 +3,20 @@ package service
import "strings"
// resolveOpenAIForwardModel 解析 OpenAI 兼容转发使用的模型。
// defaultMappedModel 只服务于 /v1/messages 的 Claude 系列显式调度映射,
// 不作为普通 OpenAI 请求的未知模型兜底。
func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedModel string) string {
// messagesDispatchMappedModel 是调用方已为 /v1/messages 解析的显式调度结果;
// 普通 OpenAI 请求必须传空,避免将分组配置作为通用模型兜底。
func resolveOpenAIForwardModel(account *Account, requestedModel, messagesDispatchMappedModel string) string {
messagesDispatchMappedModel = strings.TrimSpace(messagesDispatchMappedModel)
if account == nil {
if defaultMappedModel != "" && claudeMessagesDispatchFamily(requestedModel) != "" {
return defaultMappedModel
if messagesDispatchMappedModel != "" {
return messagesDispatchMappedModel
}
return requestedModel
}
mappedModel, matched := account.ResolveMappedModel(requestedModel)
if !matched && defaultMappedModel != "" && claudeMessagesDispatchFamily(requestedModel) != "" {
return defaultMappedModel
if !matched && messagesDispatchMappedModel != "" {
return messagesDispatchMappedModel
}
return mappedModel
}
@@ -4,159 +4,156 @@ import "testing"
func TestResolveOpenAIForwardModel(t *testing.T) {
tests := []struct {
name string
account *Account
requestedModel string
defaultMappedModel string
expectedModel string
name string
account *Account
requestedModel string
messagesDispatchMappedModel string
expectedModel string
}{
{
name: "uses messages dispatch default for claude model",
name: "uses messages dispatch model for known claude family",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "claude-opus-4-6",
defaultMappedModel: "gpt-4o-mini",
expectedModel: "gpt-4o-mini",
requestedModel: "claude-opus-4-6",
messagesDispatchMappedModel: "gpt-4o-mini",
expectedModel: "gpt-4o-mini",
},
{
name: "does not fall back to group default for invalid gpt model",
name: "uses exact messages dispatch model for unknown claude family",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt6",
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt6",
requestedModel: "claude-fable-5",
messagesDispatchMappedModel: " gpt-5.6-sol ",
expectedModel: "gpt-5.6-sol",
},
{
name: "preserves explicit gpt-5.4 instead of group default",
name: "nil account uses messages dispatch model",
requestedModel: "claude-fable-5",
messagesDispatchMappedModel: "gpt-5.6-sol",
expectedModel: "gpt-5.6-sol",
},
{
name: "nil account without messages dispatch keeps requested model",
requestedModel: "claude-fable-5",
expectedModel: "claude-fable-5",
},
{
name: "ordinary unknown gpt model has no messages dispatch fallback",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.4",
defaultMappedModel: "gpt-4o-mini",
expectedModel: "gpt-5.4",
requestedModel: "gpt6",
expectedModel: "gpt6",
},
{
name: "preserves exact passthrough mapping instead of group default",
name: "account exact mapping overrides messages dispatch model",
account: &Account{
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.4": "gpt-5.4",
"claude-fable-5": "gpt-5.5",
},
},
},
requestedModel: "gpt-5.4",
defaultMappedModel: "gpt-4o-mini",
expectedModel: "gpt-5.4",
requestedModel: "claude-fable-5",
messagesDispatchMappedModel: "gpt-5.6-sol",
expectedModel: "gpt-5.5",
},
{
name: "preserves wildcard passthrough mapping instead of group default",
name: "account wildcard mapping overrides messages dispatch model",
account: &Account{
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-*": "gpt-5.4",
"claude-*": "gpt-5.4",
},
},
},
requestedModel: "gpt-5.4",
defaultMappedModel: "gpt-4o-mini",
expectedModel: "gpt-5.4",
requestedModel: "claude-fable-5",
messagesDispatchMappedModel: "gpt-5.6-sol",
expectedModel: "gpt-5.4",
},
{
name: "uses account remap when explicit target differs",
name: "account passthrough mapping overrides messages dispatch model",
account: &Account{
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5": "gpt-5.4",
"claude-fable-5": "claude-fable-5",
},
},
},
requestedModel: "gpt-5",
defaultMappedModel: "gpt-4o-mini",
expectedModel: "gpt-5.4",
requestedModel: "claude-fable-5",
messagesDispatchMappedModel: "gpt-5.6-sol",
expectedModel: "claude-fable-5",
},
{
name: "preserves codex spark instead of group default",
name: "ordinary codex spark request keeps requested model",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.3-codex-spark",
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt-5.3-codex-spark",
requestedModel: "gpt-5.3-codex-spark",
expectedModel: "gpt-5.3-codex-spark",
},
{
name: "preserves gpt-5.5 instead of group default",
name: "ordinary gpt-5.5 request keeps requested model",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.5",
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt-5.5",
requestedModel: "gpt-5.5",
expectedModel: "gpt-5.5",
},
{
name: "preserves gpt-5.5-pro instead of group default",
name: "ordinary gpt-5.5-pro request keeps requested model",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.5-pro",
defaultMappedModel: "gpt-5.5",
expectedModel: "gpt-5.5-pro",
requestedModel: "gpt-5.5-pro",
expectedModel: "gpt-5.5-pro",
},
{
name: "preserves compact-spelled gpt5.5 instead of group default",
name: "ordinary compact-spelled gpt5.5 request keeps requested model",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt5.5",
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt5.5",
requestedModel: "gpt5.5",
expectedModel: "gpt5.5",
},
{
name: "preserves openai namespaced gpt-5.5 instead of group default",
name: "ordinary namespaced gpt-5.5 request keeps requested model",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "openai/gpt-5.5",
defaultMappedModel: "gpt-5.4",
expectedModel: "openai/gpt-5.5",
requestedModel: "openai/gpt-5.5",
expectedModel: "openai/gpt-5.5",
},
{
name: "preserves compact gpt-5.5 instead of group default",
name: "ordinary compact gpt-5.5 request keeps requested model",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.5-openai-compact",
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt-5.5-openai-compact",
requestedModel: "gpt-5.5-openai-compact",
expectedModel: "gpt-5.5-openai-compact",
},
{
name: "whitespace-only messages dispatch model is ignored",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.5",
messagesDispatchMappedModel: " ",
expectedModel: "gpt-5.5",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolveOpenAIForwardModel(tt.account, tt.requestedModel, tt.defaultMappedModel); got != tt.expectedModel {
if got := resolveOpenAIForwardModel(tt.account, tt.requestedModel, tt.messagesDispatchMappedModel); got != tt.expectedModel {
t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", got, tt.expectedModel)
}
})
}
}
func TestResolveOpenAIForwardModel_PreventsClaudeModelFromFallingBackToGpt54(t *testing.T) {
account := &Account{
Credentials: map[string]any{},
}
withoutDefault := resolveOpenAIForwardModel(account, "claude-opus-4-6", "")
if withoutDefault != "claude-opus-4-6" {
t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", withoutDefault, "claude-opus-4-6")
}
withDefault := resolveOpenAIForwardModel(account, "claude-opus-4-6", "gpt-5.4")
if withDefault != "gpt-5.4" {
t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", withDefault, "gpt-5.4")
}
}
func TestResolveOpenAICompactForwardModel(t *testing.T) {
tests := []struct {
name string