mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #4179 from scientist-blockchian/fix/messages-exact-model-mapping
fix(openai): preserve exact messages dispatch mappings
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user