Skip to content

Commit df722c9

Browse files
committed
fix: remove OpenAI unknown model fallback
1 parent d9e68f2 commit df722c9

10 files changed

Lines changed: 211 additions & 134 deletions

backend/internal/handler/openai_chat_completions.go

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
121121
var lastFailoverErr *service.UpstreamFailoverError
122122

123123
for {
124-
c.Set("openai_chat_completions_fallback_model", "")
125124
reqLog.Debug("openai_chat_completions.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
126125
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
127126
c.Request.Context(),
@@ -139,32 +138,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
139138
zap.Int("excluded_account_count", len(failedAccountIDs)),
140139
)
141140
if len(failedAccountIDs) == 0 {
142-
defaultModel := ""
143-
if apiKey.Group != nil {
144-
defaultModel = apiKey.Group.DefaultMappedModel
145-
}
146-
if defaultModel != "" && defaultModel != reqModel {
147-
reqLog.Info("openai_chat_completions.fallback_to_default_model",
148-
zap.String("default_mapped_model", defaultModel),
149-
)
150-
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler(
151-
c.Request.Context(),
152-
apiKey.GroupID,
153-
"",
154-
sessionHash,
155-
defaultModel,
156-
failedAccountIDs,
157-
service.OpenAIUpstreamTransportAny,
158-
false,
159-
)
160-
if err == nil && selection != nil {
161-
c.Set("openai_chat_completions_fallback_model", defaultModel)
162-
}
163-
}
164-
if err != nil {
165-
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
166-
return
167-
}
141+
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
142+
return
168143
} else {
169144
if lastFailoverErr != nil {
170145
h.handleFailoverExhausted(c, lastFailoverErr, streamStarted)
@@ -192,12 +167,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
192167
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
193168
forwardStart := time.Now()
194169

195-
defaultMappedModel := resolveOpenAIForwardDefaultMappedModel(apiKey, c.GetString("openai_chat_completions_fallback_model"))
196170
forwardBody := body
197171
if channelMapping.Mapped {
198172
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
199173
}
200-
result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel)
174+
result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "")
201175

202176
forwardDurationMs := time.Since(forwardStart).Milliseconds()
203177
if accountReleaseFunc != nil {

backend/internal/handler/openai_gateway_handler.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,6 @@ type OpenAIGatewayHandler struct {
3737
cfg *config.Config
3838
}
3939

40-
func resolveOpenAIForwardDefaultMappedModel(apiKey *service.APIKey, fallbackModel string) string {
41-
if fallbackModel = strings.TrimSpace(fallbackModel); fallbackModel != "" {
42-
return fallbackModel
43-
}
44-
if apiKey == nil || apiKey.Group == nil {
45-
return ""
46-
}
47-
return strings.TrimSpace(apiKey.Group.DefaultMappedModel)
48-
}
49-
5040
func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
5141
if apiKey == nil || apiKey.Group == nil {
5242
return ""

backend/internal/handler/openai_gateway_handler_test.go

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -353,30 +353,6 @@ func TestOpenAIEnsureResponsesDependencies(t *testing.T) {
353353
})
354354
}
355355

356-
func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) {
357-
t.Run("prefers_explicit_fallback_model", func(t *testing.T) {
358-
apiKey := &service.APIKey{
359-
Group: &service.Group{DefaultMappedModel: "gpt-5.4"},
360-
}
361-
require.Equal(t, "gpt-5.2", resolveOpenAIForwardDefaultMappedModel(apiKey, " gpt-5.2 "))
362-
})
363-
364-
t.Run("uses_group_default_when_explicit_fallback_absent", func(t *testing.T) {
365-
apiKey := &service.APIKey{
366-
Group: &service.Group{DefaultMappedModel: "gpt-5.4"},
367-
}
368-
require.Equal(t, "gpt-5.4", resolveOpenAIForwardDefaultMappedModel(apiKey, ""))
369-
})
370-
371-
t.Run("returns_empty_without_group_default", func(t *testing.T) {
372-
require.Empty(t, resolveOpenAIForwardDefaultMappedModel(nil, ""))
373-
require.Empty(t, resolveOpenAIForwardDefaultMappedModel(&service.APIKey{}, ""))
374-
require.Empty(t, resolveOpenAIForwardDefaultMappedModel(&service.APIKey{
375-
Group: &service.Group{},
376-
}, ""))
377-
})
378-
}
379-
380356
func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
381357
t.Run("exact_claude_model_override_wins", func(t *testing.T) {
382358
apiKey := &service.APIKey{

backend/internal/service/billing_service.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ func (s *BillingService) initFallbackPricing() {
226226
CacheReadPricePerToken: 7.5e-8,
227227
SupportsCacheBreakdown: false,
228228
}
229+
s.fallbackPrices["gpt-5.4-nano"] = &ModelPricing{
230+
InputPricePerToken: 2e-7,
231+
OutputPricePerToken: 1.25e-6,
232+
CacheReadPricePerToken: 2e-8,
233+
SupportsCacheBreakdown: false,
234+
}
229235
// OpenAI GPT-5.2(本地兜底)
230236
s.fallbackPrices["gpt-5.2"] = &ModelPricing{
231237
InputPricePerToken: 1.75e-6,
@@ -295,6 +301,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
295301
return s.fallbackPrices["gpt-5.5"]
296302
case "gpt-5.4-mini":
297303
return s.fallbackPrices["gpt-5.4-mini"]
304+
case "gpt-5.4-nano":
305+
return s.fallbackPrices["gpt-5.4-nano"]
298306
case "gpt-5.4":
299307
return s.fallbackPrices["gpt-5.4"]
300308
case "gpt-5.2":

backend/internal/service/openai_codex_transform.go

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ var codexModelMap = map[string]string{
3838
"gpt-5.2-medium": "gpt-5.2",
3939
"gpt-5.2-high": "gpt-5.2",
4040
"gpt-5.2-xhigh": "gpt-5.2",
41+
"gpt-5": "gpt-5.4",
42+
"gpt-5-mini": "gpt-5.4",
43+
"gpt-5-nano": "gpt-5.4",
44+
"gpt-5.1": "gpt-5.4",
45+
"gpt-5.1-codex": "gpt-5.3-codex",
46+
"gpt-5.1-codex-max": "gpt-5.3-codex",
47+
"gpt-5.1-codex-mini": "gpt-5.3-codex",
48+
"gpt-5.2-codex": "gpt-5.2",
49+
"codex-mini-latest": "gpt-5.3-codex",
50+
"gpt-5-codex": "gpt-5.3-codex",
51+
}
52+
53+
var codexVersionModelPrefixes = []struct {
54+
prefix string
55+
target string
56+
}{
57+
{prefix: "gpt-5.3-codex-spark", target: "gpt-5.3-codex-spark"},
58+
{prefix: "gpt-5.3-codex", target: "gpt-5.3-codex"},
59+
{prefix: "gpt-5.4-mini", target: "gpt-5.4-mini"},
60+
{prefix: "gpt-5.4-nano", target: "gpt-5.4-nano"},
61+
{prefix: "gpt-5.5", target: "gpt-5.5"},
62+
{prefix: "gpt-5.4", target: "gpt-5.4"},
63+
{prefix: "gpt-5.2", target: "gpt-5.2"},
4164
}
4265

4366
type codexTransformResult struct {
@@ -447,8 +470,19 @@ func normalizeCodexModel(model string) string {
447470
if model == "" {
448471
return "gpt-5.4"
449472
}
473+
if mapped, ok := normalizeKnownCodexModel(model); ok {
474+
return mapped
475+
}
476+
return model
477+
}
478+
479+
func normalizeKnownCodexModel(model string) (string, bool) {
480+
model = strings.TrimSpace(model)
481+
if model == "" {
482+
return "", false
483+
}
450484
if isOpenAIImageGenerationModel(model) {
451-
return model
485+
return model, true
452486
}
453487

454488
modelID := model
@@ -457,41 +491,58 @@ func normalizeCodexModel(model string) string {
457491
modelID = parts[len(parts)-1]
458492
}
459493

460-
if mapped := getNormalizedCodexModel(modelID); mapped != "" {
461-
return mapped
494+
key := codexModelLookupKey(modelID)
495+
if key == "" {
496+
return "", false
462497
}
463-
464-
normalized := strings.ToLower(modelID)
465-
466-
if strings.Contains(normalized, "gpt-5.5") || strings.Contains(normalized, "gpt 5.5") {
467-
return "gpt-5.5"
468-
}
469-
if strings.Contains(normalized, "gpt-5.4-mini") || strings.Contains(normalized, "gpt 5.4 mini") {
470-
return "gpt-5.4-mini"
471-
}
472-
if strings.Contains(normalized, "gpt-5.4") || strings.Contains(normalized, "gpt 5.4") {
473-
return "gpt-5.4"
498+
if mapped := getNormalizedCodexModel(key); mapped != "" {
499+
return mapped, true
474500
}
475-
if strings.Contains(normalized, "gpt-5.2") || strings.Contains(normalized, "gpt 5.2") {
476-
return "gpt-5.2"
501+
for _, item := range codexVersionModelPrefixes {
502+
if key == item.prefix {
503+
return item.target, true
504+
}
505+
suffix, ok := strings.CutPrefix(key, item.prefix+"-")
506+
if ok && isKnownCodexModelSuffix(suffix) {
507+
return item.target, true
508+
}
477509
}
478-
if strings.Contains(normalized, "gpt-5.3-codex-spark") || strings.Contains(normalized, "gpt 5.3 codex spark") {
479-
return "gpt-5.3-codex-spark"
510+
return "", false
511+
}
512+
513+
func codexModelLookupKey(modelID string) string {
514+
modelID = strings.TrimSpace(modelID)
515+
if modelID == "" {
516+
return ""
480517
}
481-
if strings.Contains(normalized, "gpt-5.3-codex") || strings.Contains(normalized, "gpt 5.3 codex") {
482-
return "gpt-5.3-codex"
518+
if strings.Contains(modelID, "/") {
519+
parts := strings.Split(modelID, "/")
520+
modelID = parts[len(parts)-1]
483521
}
484-
if strings.Contains(normalized, "gpt-5.3") || strings.Contains(normalized, "gpt 5.3") {
485-
return "gpt-5.3-codex"
522+
return strings.ToLower(strings.Join(strings.Fields(modelID), "-"))
523+
}
524+
525+
func isKnownCodexModelSuffix(suffix string) bool {
526+
switch suffix {
527+
case "none", "minimal", "low", "medium", "high", "xhigh":
528+
return true
486529
}
487-
if strings.Contains(normalized, "codex") {
488-
return "gpt-5.3-codex"
530+
return isCodexDateSuffix(suffix)
531+
}
532+
533+
func isCodexDateSuffix(suffix string) bool {
534+
parts := strings.Split(suffix, "-")
535+
if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 {
536+
return false
489537
}
490-
if strings.Contains(normalized, "gpt-5") || strings.Contains(normalized, "gpt 5") {
491-
return "gpt-5.4"
538+
for _, part := range parts {
539+
for _, r := range part {
540+
if r < '0' || r > '9' {
541+
return false
542+
}
543+
}
492544
}
493-
494-
return "gpt-5.4"
545+
return true
495546
}
496547

497548
func isCodexSparkModel(model string) bool {
@@ -789,18 +840,13 @@ func SupportsVerbosity(model string) bool {
789840
}
790841

791842
func getNormalizedCodexModel(modelID string) string {
792-
if modelID == "" {
843+
key := codexModelLookupKey(modelID)
844+
if key == "" {
793845
return ""
794846
}
795-
if mapped, ok := codexModelMap[modelID]; ok {
847+
if mapped, ok := codexModelMap[key]; ok {
796848
return mapped
797849
}
798-
lower := strings.ToLower(modelID)
799-
for key, value := range codexModelMap {
800-
if strings.ToLower(key) == lower {
801-
return value
802-
}
803-
}
804850
return ""
805851
}
806852

backend/internal/service/openai_gateway_chat_completions_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,42 @@ func TestNormalizeResponsesBodyServiceTier(t *testing.T) {
9797
require.False(t, gjson.GetBytes(body, "service_tier").Exists())
9898
}
9999

100+
func TestForwardAsChatCompletions_UnknownModelDoesNotUseDefaultMappedModel(t *testing.T) {
101+
gin.SetMode(gin.TestMode)
102+
103+
rec := httptest.NewRecorder()
104+
c, _ := gin.CreateTestContext(rec)
105+
body := []byte(`{"model":"gpt6","messages":[{"role":"user","content":"hello"}],"stream":false}`)
106+
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
107+
c.Request.Header.Set("Content-Type", "application/json")
108+
109+
upstream := &httpUpstreamRecorder{resp: &http.Response{
110+
StatusCode: http.StatusBadRequest,
111+
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_unknown_model"}},
112+
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"model not found"}}`)),
113+
}}
114+
115+
svc := &OpenAIGatewayService{httpUpstream: upstream}
116+
account := &Account{
117+
ID: 1,
118+
Name: "openai-oauth",
119+
Platform: PlatformOpenAI,
120+
Type: AccountTypeOAuth,
121+
Concurrency: 1,
122+
Credentials: map[string]any{
123+
"access_token": "oauth-token",
124+
"chatgpt_account_id": "chatgpt-acc",
125+
},
126+
}
127+
128+
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
129+
require.Error(t, err)
130+
require.Nil(t, result)
131+
require.Equal(t, "gpt6", gjson.GetBytes(upstream.lastBody, "model").String())
132+
require.NotEqual(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
133+
require.Equal(t, http.StatusBadRequest, rec.Code)
134+
}
135+
100136
func TestForwardAsChatCompletions_ClientDisconnectDrainsUpstreamUsage(t *testing.T) {
101137
gin.SetMode(gin.TestMode)
102138

backend/internal/service/openai_gateway_record_usage_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,9 +1006,8 @@ func TestOpenAIGatewayServiceRecordUsage_ChannelMappedDoesNotOverrideBillingMode
10061006
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
10071007
usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10}
10081008

1009-
// When channel did NOT map the model (ChannelMappedModel == OriginalModel),
1010-
// billing should use result.BillingModel (the actual model used after group
1011-
// DefaultMappedModel resolution), not the unmapped original model.
1009+
// 渠道未发生模型映射时,应使用 result.BillingModel 中记录的实际上游计费模型,
1010+
// 而不是未映射的原始请求模型。
10121011
expectedCost, err := svc.billingService.CalculateCost("gpt-5.1", UsageTokens{
10131012
InputTokens: 20,
10141013
OutputTokens: 10,

backend/internal/service/openai_model_mapping.go

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,24 @@ package service
22

33
import "strings"
44

5-
// resolveOpenAIForwardModel determines the upstream model for OpenAI-compatible
6-
// forwarding. Group-level default mapping only applies when the account itself
7-
// did not match any explicit model_mapping rule.
5+
// resolveOpenAIForwardModel 解析 OpenAI 兼容转发使用的模型。
6+
// defaultMappedModel 只服务于 /v1/messages 的 Claude 系列显式调度映射,
7+
// 不作为普通 OpenAI 请求的未知模型兜底。
88
func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedModel string) string {
99
if account == nil {
10-
if defaultMappedModel != "" {
10+
if defaultMappedModel != "" && claudeMessagesDispatchFamily(requestedModel) != "" {
1111
return defaultMappedModel
1212
}
1313
return requestedModel
1414
}
1515

1616
mappedModel, matched := account.ResolveMappedModel(requestedModel)
17-
if !matched && defaultMappedModel != "" && !isExplicitCodexModel(requestedModel) {
17+
if !matched && defaultMappedModel != "" && claudeMessagesDispatchFamily(requestedModel) != "" {
1818
return defaultMappedModel
1919
}
2020
return mappedModel
2121
}
2222

23-
func isExplicitCodexModel(model string) bool {
24-
model = strings.TrimSpace(model)
25-
if model == "" {
26-
return false
27-
}
28-
if strings.Contains(model, "/") {
29-
parts := strings.Split(model, "/")
30-
model = parts[len(parts)-1]
31-
}
32-
model = strings.ToLower(strings.TrimSpace(model))
33-
if getNormalizedCodexModel(model) != "" {
34-
return true
35-
}
36-
if strings.HasSuffix(model, "-openai-compact") {
37-
base := strings.TrimSuffix(model, "-openai-compact")
38-
return getNormalizedCodexModel(base) != ""
39-
}
40-
return false
41-
}
42-
4323
// resolveOpenAICompactForwardModel determines the compact-only upstream model
4424
// for /responses/compact requests. It never affects normal /responses traffic.
4525
// When no compact-specific mapping matches, the input model is returned as-is.

0 commit comments

Comments
 (0)