diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 7eeae58afd..ba217164ad 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -1866,13 +1866,12 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle OpenAI accounts
if account.IsOpenAI() {
- // For OAuth accounts: return default OpenAI models
- if account.IsOAuth() {
+ // OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
+ if account.IsOpenAIPassthroughEnabled() {
response.Success(c, openai.DefaultModels)
return
}
- // For API Key accounts: check model_mapping
mapping := account.GetModelMapping()
if len(mapping) == 0 {
response.Success(c, openai.DefaultModels)
diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go
new file mode 100644
index 0000000000..c5f1e2d884
--- /dev/null
+++ b/backend/internal/handler/admin/account_handler_available_models_test.go
@@ -0,0 +1,105 @@
+package admin
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type availableModelsAdminService struct {
+ *stubAdminService
+ account service.Account
+}
+
+func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*service.Account, error) {
+ if s.account.ID == id {
+ acc := s.account
+ return &acc, nil
+ }
+ return s.stubAdminService.GetAccount(context.Background(), id)
+}
+
+func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
+ return router
+}
+
+func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
+ svc := &availableModelsAdminService{
+ stubAdminService: newStubAdminService(),
+ account: service.Account{
+ ID: 42,
+ Name: "openai-oauth",
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ Status: service.StatusActive,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5": "gpt-5.1",
+ },
+ },
+ },
+ }
+ router := setupAvailableModelsRouter(svc)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/42/models", nil)
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var resp struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.Len(t, resp.Data, 1)
+ require.Equal(t, "gpt-5", resp.Data[0].ID)
+}
+
+func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefaults(t *testing.T) {
+ svc := &availableModelsAdminService{
+ stubAdminService: newStubAdminService(),
+ account: service.Account{
+ ID: 43,
+ Name: "openai-oauth-passthrough",
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ Status: service.StatusActive,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5": "gpt-5.1",
+ },
+ },
+ Extra: map[string]any{
+ "openai_passthrough": true,
+ },
+ },
+ }
+ router := setupAvailableModelsRouter(svc)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/43/models", nil)
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var resp struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.NotEmpty(t, resp.Data)
+ require.NotEqual(t, "gpt-5", resp.Data[0].ID)
+}
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 6900e7cd1f..724376e312 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -181,13 +181,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
- defaultMappedModel := ""
- if apiKey.Group != nil {
- defaultMappedModel = apiKey.Group.DefaultMappedModel
- }
- if fallbackModel := c.GetString("openai_chat_completions_fallback_model"); fallbackModel != "" {
- defaultMappedModel = fallbackModel
- }
+ defaultMappedModel := c.GetString("openai_chat_completions_fallback_model")
result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, body, promptCacheKey, defaultMappedModel)
forwardDurationMs := time.Since(forwardStart).Milliseconds()
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index d23c7efeda..87b0d0d694 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -655,14 +655,9 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
- defaultMappedModel := ""
- if apiKey.Group != nil {
- defaultMappedModel = apiKey.Group.DefaultMappedModel
- }
- // 如果使用了降级模型调度,强制使用降级模型
- if fallbackModel := c.GetString("openai_messages_fallback_model"); fallbackModel != "" {
- defaultMappedModel = fallbackModel
- }
+ // 仅在调度时实际触发了降级(原模型无可用账号、改用默认模型重试成功)时,
+ // 才将降级模型传给 Forward 层做模型替换;否则保持用户请求的原始模型。
+ defaultMappedModel := c.GetString("openai_messages_fallback_model")
result, err := h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, body, promptCacheKey, defaultMappedModel)
forwardDurationMs := time.Since(forwardStart).Milliseconds()
diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go
index 1c1d39bb83..2db65572c1 100644
--- a/backend/internal/pkg/apicompat/anthropic_responses_test.go
+++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go
@@ -105,6 +105,7 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) {
assert.Equal(t, "assistant", items[1].Role)
assert.Equal(t, "function_call", items[2].Type)
assert.Equal(t, "fc_call_1", items[2].CallID)
+ assert.Empty(t, items[2].ID)
assert.Equal(t, "function_call_output", items[3].Type)
assert.Equal(t, "fc_call_1", items[3].CallID)
assert.Equal(t, "Sunny, 72°F", items[3].Output)
diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go
index 592bec39cc..0a747869a1 100644
--- a/backend/internal/pkg/apicompat/anthropic_to_responses.go
+++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go
@@ -277,7 +277,6 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
CallID: fcID,
Name: b.Name,
Arguments: args,
- ID: fcID,
})
}
diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go
index 71b7a6f518..8b819033a8 100644
--- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go
+++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go
@@ -99,6 +99,7 @@ func TestChatCompletionsToResponses_ToolCalls(t *testing.T) {
// Check function_call item
assert.Equal(t, "function_call", items[1].Type)
assert.Equal(t, "call_1", items[1].CallID)
+ assert.Empty(t, items[1].ID)
assert.Equal(t, "ping", items[1].Name)
// Check function_call_output item
@@ -252,6 +253,55 @@ func TestChatCompletionsToResponses_AssistantWithTextAndToolCalls(t *testing.T)
assert.Equal(t, "user", items[0].Role)
assert.Equal(t, "assistant", items[1].Role)
assert.Equal(t, "function_call", items[2].Type)
+ assert.Empty(t, items[2].ID)
+}
+
+func TestChatCompletionsToResponses_AssistantArrayContentPreserved(t *testing.T) {
+ req := &ChatCompletionsRequest{
+ Model: "gpt-4o",
+ Messages: []ChatMessage{
+ {Role: "user", Content: json.RawMessage(`"Hi"`)},
+ {Role: "assistant", Content: json.RawMessage(`[{"type":"text","text":"A"},{"type":"text","text":"B"}]`)},
+ },
+ }
+
+ resp, err := ChatCompletionsToResponses(req)
+ require.NoError(t, err)
+
+ var items []ResponsesInputItem
+ require.NoError(t, json.Unmarshal(resp.Input, &items))
+ require.Len(t, items, 2)
+ assert.Equal(t, "assistant", items[1].Role)
+
+ var parts []ResponsesContentPart
+ require.NoError(t, json.Unmarshal(items[1].Content, &parts))
+ require.Len(t, parts, 1)
+ assert.Equal(t, "output_text", parts[0].Type)
+ assert.Equal(t, "AB", parts[0].Text)
+}
+
+func TestChatCompletionsToResponses_AssistantThinkingTagPreserved(t *testing.T) {
+ req := &ChatCompletionsRequest{
+ Model: "gpt-4o",
+ Messages: []ChatMessage{
+ {Role: "user", Content: json.RawMessage(`"Hi"`)},
+ {Role: "assistant", Content: json.RawMessage(`[{"type":"thinking","thinking":"internal plan"},{"type":"text","text":"final answer"}]`)},
+ },
+ }
+
+ resp, err := ChatCompletionsToResponses(req)
+ require.NoError(t, err)
+
+ var items []ResponsesInputItem
+ require.NoError(t, json.Unmarshal(resp.Input, &items))
+ require.Len(t, items, 2)
+
+ var parts []ResponsesContentPart
+ require.NoError(t, json.Unmarshal(items[1].Content, &parts))
+ require.Len(t, parts, 1)
+ assert.Equal(t, "output_text", parts[0].Type)
+ assert.Contains(t, parts[0].Text, "internal plan")
+ assert.Contains(t, parts[0].Text, "final answer")
}
// ---------------------------------------------------------------------------
@@ -344,8 +394,8 @@ func TestResponsesToChatCompletions_Reasoning(t *testing.T) {
var content string
require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &content))
- // Reasoning summary is prepended to text
- assert.Equal(t, "I thought about it.The answer is 42.", content)
+ assert.Equal(t, "The answer is 42.", content)
+ assert.Equal(t, "I thought about it.", chat.Choices[0].Message.ReasoningContent)
}
func TestResponsesToChatCompletions_Incomplete(t *testing.T) {
@@ -582,8 +632,35 @@ func TestResponsesEventToChatChunks_ReasoningDelta(t *testing.T) {
Delta: "Thinking...",
}, state)
require.Len(t, chunks, 1)
+ require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
+ assert.Equal(t, "Thinking...", *chunks[0].Choices[0].Delta.ReasoningContent)
+
+ chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
+ Type: "response.reasoning_summary_text.done",
+ }, state)
+ require.Len(t, chunks, 0)
+}
+
+func TestResponsesEventToChatChunks_ReasoningThenTextAutoCloseTag(t *testing.T) {
+ state := NewResponsesEventToChatState()
+ state.Model = "gpt-4o"
+ state.SentRole = true
+
+ chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
+ Type: "response.reasoning_summary_text.delta",
+ Delta: "plan",
+ }, state)
+ require.Len(t, chunks, 1)
+ require.NotNil(t, chunks[0].Choices[0].Delta.ReasoningContent)
+ assert.Equal(t, "plan", *chunks[0].Choices[0].Delta.ReasoningContent)
+
+ chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
+ Type: "response.output_text.delta",
+ Delta: "answer",
+ }, state)
+ require.Len(t, chunks, 1)
require.NotNil(t, chunks[0].Choices[0].Delta.Content)
- assert.Equal(t, "Thinking...", *chunks[0].Choices[0].Delta.Content)
+ assert.Equal(t, "answer", *chunks[0].Choices[0].Delta.Content)
}
func TestFinalizeResponsesChatStream(t *testing.T) {
diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go
index 37285b09d7..c4a9e773e9 100644
--- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go
+++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go
@@ -3,6 +3,7 @@ package apicompat
import (
"encoding/json"
"fmt"
+ "strings"
)
// ChatCompletionsToResponses converts a Chat Completions request into a
@@ -174,8 +175,11 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
// Emit assistant message with output_text if content is non-empty.
if len(m.Content) > 0 {
- var s string
- if err := json.Unmarshal(m.Content, &s); err == nil && s != "" {
+ s, err := parseAssistantContent(m.Content)
+ if err != nil {
+ return nil, err
+ }
+ if s != "" {
parts := []ResponsesContentPart{{Type: "output_text", Text: s}}
partsJSON, err := json.Marshal(parts)
if err != nil {
@@ -196,13 +200,82 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
CallID: tc.ID,
Name: tc.Function.Name,
Arguments: args,
- ID: tc.ID,
})
}
return items, nil
}
+// parseAssistantContent returns assistant content as plain text.
+//
+// Supported formats:
+// - JSON string
+// - JSON array of typed parts (e.g. [{"type":"text","text":"..."}])
+//
+// For structured thinking/reasoning parts, it preserves semantics by wrapping
+// the text in explicit tags so downstream can still distinguish it from normal text.
+func parseAssistantContent(raw json.RawMessage) (string, error) {
+ if len(raw) == 0 {
+ return "", nil
+ }
+
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return s, nil
+ }
+
+ var parts []map[string]any
+ if err := json.Unmarshal(raw, &parts); err != nil {
+ // Keep compatibility with prior behavior: unsupported assistant content
+ // formats are ignored instead of failing the whole request conversion.
+ return "", nil
+ }
+
+ var b strings.Builder
+ write := func(v string) error {
+ _, err := b.WriteString(v)
+ return err
+ }
+ for _, p := range parts {
+ typ, _ := p["type"].(string)
+ text, _ := p["text"].(string)
+ thinking, _ := p["thinking"].(string)
+
+ switch typ {
+ case "thinking", "reasoning":
+ if thinking != "" {
+ if err := write(""); err != nil {
+ return "", err
+ }
+ if err := write(thinking); err != nil {
+ return "", err
+ }
+ if err := write(""); err != nil {
+ return "", err
+ }
+ } else if text != "" {
+ if err := write(""); err != nil {
+ return "", err
+ }
+ if err := write(text); err != nil {
+ return "", err
+ }
+ if err := write(""); err != nil {
+ return "", err
+ }
+ }
+ default:
+ if text != "" {
+ if err := write(text); err != nil {
+ return "", err
+ }
+ }
+ }
+ }
+
+ return b.String(), nil
+}
+
// chatToolToResponses converts a tool result message (role=tool) into a
// function_call_output item.
func chatToolToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go
index 8f83bce490..688a68ebf8 100644
--- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go
+++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go
@@ -29,6 +29,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
}
var contentText string
+ var reasoningText string
var toolCalls []ChatToolCall
for _, item := range resp.Output {
@@ -51,7 +52,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
case "reasoning":
for _, s := range item.Summary {
if s.Type == "summary_text" && s.Text != "" {
- contentText += s.Text
+ reasoningText += s.Text
}
}
case "web_search_call":
@@ -67,6 +68,9 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
raw, _ := json.Marshal(contentText)
msg.Content = raw
}
+ if reasoningText != "" {
+ msg.ReasoningContent = reasoningText
+ }
finishReason := responsesStatusToChatFinishReason(resp.Status, resp.IncompleteDetails, toolCalls)
@@ -153,6 +157,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent
return resToChatHandleFuncArgsDelta(evt, state)
case "response.reasoning_summary_text.delta":
return resToChatHandleReasoningDelta(evt, state)
+ case "response.reasoning_summary_text.done":
+ return nil
case "response.completed", "response.incomplete", "response.failed":
return resToChatHandleCompleted(evt, state)
default:
@@ -276,8 +282,8 @@ func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEv
if evt.Delta == "" {
return nil
}
- content := evt.Delta
- return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{Content: &content})}
+ reasoning := evt.Delta
+ return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{ReasoningContent: &reasoning})}
}
func resToChatHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go
index eb77d89fa1..b724a5ed96 100644
--- a/backend/internal/pkg/apicompat/types.go
+++ b/backend/internal/pkg/apicompat/types.go
@@ -361,11 +361,12 @@ type ChatStreamOptions struct {
// ChatMessage is a single message in the Chat Completions conversation.
type ChatMessage struct {
- Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function"
- Content json.RawMessage `json:"content,omitempty"`
- Name string `json:"name,omitempty"`
- ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
- ToolCallID string `json:"tool_call_id,omitempty"`
+ Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function"
+ Content json.RawMessage `json:"content,omitempty"`
+ ReasoningContent string `json:"reasoning_content,omitempty"`
+ Name string `json:"name,omitempty"`
+ ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
// Legacy function calling
FunctionCall *ChatFunctionCall `json:"function_call,omitempty"`
@@ -466,9 +467,10 @@ type ChatChunkChoice struct {
// ChatDelta carries incremental content in a streaming chunk.
type ChatDelta struct {
- Role string `json:"role,omitempty"`
- Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters
- ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
+ Role string `json:"role,omitempty"`
+ Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters
+ ReasoningContent *string `json:"reasoning_content,omitempty"`
+ ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
}
// ---------------------------------------------------------------------------
diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go
index a9cb2cba5f..a0e1746979 100644
--- a/backend/internal/repository/account_repo.go
+++ b/backend/internal/repository/account_repo.go
@@ -397,9 +397,9 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &account.ID, nil, buildSchedulerGroupPayload(account.GroupIDs)); err != nil {
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue account update failed: account=%d err=%v", account.ID, err)
}
- if account.Status == service.StatusError || account.Status == service.StatusDisabled || !account.Schedulable {
- r.syncSchedulerAccountSnapshot(ctx, account.ID)
- }
+ // 普通账号编辑(如 model_mapping / credentials)也需要立即刷新单账号快照,
+ // 否则网关在 outbox worker 延迟或异常时仍可能读到旧配置。
+ r.syncSchedulerAccountSnapshot(ctx, account.ID)
return nil
}
diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go
index 29b699e6a1..e697802e3a 100644
--- a/backend/internal/repository/account_repo_integration_test.go
+++ b/backend/internal/repository/account_repo_integration_test.go
@@ -142,6 +142,35 @@ func (s *AccountRepoSuite) TestUpdate_SyncSchedulerSnapshotOnDisabled() {
s.Require().Equal(service.StatusDisabled, cacheRecorder.setAccounts[0].Status)
}
+func (s *AccountRepoSuite) TestUpdate_SyncSchedulerSnapshotOnCredentialsChange() {
+ account := mustCreateAccount(s.T(), s.client, &service.Account{
+ Name: "sync-credentials-update",
+ Status: service.StatusActive,
+ Schedulable: true,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5": "gpt-5.1",
+ },
+ },
+ })
+ cacheRecorder := &schedulerCacheRecorder{}
+ s.repo.schedulerCache = cacheRecorder
+
+ account.Credentials = map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5": "gpt-5.2",
+ },
+ }
+ err := s.repo.Update(s.ctx, account)
+ s.Require().NoError(err, "Update")
+
+ s.Require().Len(cacheRecorder.setAccounts, 1)
+ s.Require().Equal(account.ID, cacheRecorder.setAccounts[0].ID)
+ mapping, ok := cacheRecorder.setAccounts[0].Credentials["model_mapping"].(map[string]any)
+ s.Require().True(ok)
+ s.Require().Equal("gpt-5.2", mapping["gpt-5"])
+}
+
func (s *AccountRepoSuite) TestDelete() {
account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "to-delete"})
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index b3ae506caf..f6c0d517d1 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -522,16 +522,23 @@ func (a *Account) IsModelSupported(requestedModel string) bool {
// GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
// 如果未配置 mapping,返回原始模型名
func (a *Account) GetMappedModel(requestedModel string) string {
+ mappedModel, _ := a.ResolveMappedModel(requestedModel)
+ return mappedModel
+}
+
+// ResolveMappedModel 获取映射后的模型名,并返回是否命中了账号级映射。
+// matched=true 表示命中了精确映射或通配符映射,即使映射结果与原模型名相同。
+func (a *Account) ResolveMappedModel(requestedModel string) (mappedModel string, matched bool) {
mapping := a.GetModelMapping()
if len(mapping) == 0 {
- return requestedModel
+ return requestedModel, false
}
// 精确匹配优先
if mappedModel, exists := mapping[requestedModel]; exists {
- return mappedModel
+ return mappedModel, true
}
// 通配符匹配(最长优先)
- return matchWildcardMapping(mapping, requestedModel)
+ return matchWildcardMappingResult(mapping, requestedModel)
}
func (a *Account) GetBaseURL() string {
@@ -605,9 +612,7 @@ func matchWildcard(pattern, str string) bool {
return matchAntigravityWildcard(pattern, str)
}
-// matchWildcardMapping 通配符映射匹配(最长优先)
-// 如果没有匹配,返回原始字符串
-func matchWildcardMapping(mapping map[string]string, requestedModel string) string {
+func matchWildcardMappingResult(mapping map[string]string, requestedModel string) (string, bool) {
// 收集所有匹配的 pattern,按长度降序排序(最长优先)
type patternMatch struct {
pattern string
@@ -622,7 +627,7 @@ func matchWildcardMapping(mapping map[string]string, requestedModel string) stri
}
if len(matches) == 0 {
- return requestedModel // 无匹配,返回原始模型名
+ return requestedModel, false // 无匹配,返回原始模型名
}
// 按 pattern 长度降序排序
@@ -633,7 +638,7 @@ func matchWildcardMapping(mapping map[string]string, requestedModel string) stri
return matches[i].pattern < matches[j].pattern
})
- return matches[0].target
+ return matches[0].target, true
}
func (a *Account) IsCustomErrorCodesEnabled() bool {
diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go
index 7782f948b7..0d7ffffa8a 100644
--- a/backend/internal/service/account_wildcard_test.go
+++ b/backend/internal/service/account_wildcard_test.go
@@ -43,12 +43,13 @@ func TestMatchWildcard(t *testing.T) {
}
}
-func TestMatchWildcardMapping(t *testing.T) {
+func TestMatchWildcardMappingResult(t *testing.T) {
tests := []struct {
name string
mapping map[string]string
requestedModel string
expected string
+ matched bool
}{
// 精确匹配优先于通配符
{
@@ -59,6 +60,7 @@ func TestMatchWildcardMapping(t *testing.T) {
},
requestedModel: "claude-sonnet-4-5",
expected: "claude-sonnet-4-5-exact",
+ matched: true,
},
// 最长通配符优先
@@ -71,6 +73,7 @@ func TestMatchWildcardMapping(t *testing.T) {
},
requestedModel: "claude-sonnet-4-5",
expected: "claude-sonnet-4-series",
+ matched: true,
},
// 单个通配符
@@ -81,6 +84,7 @@ func TestMatchWildcardMapping(t *testing.T) {
},
requestedModel: "claude-opus-4-5",
expected: "claude-mapped",
+ matched: true,
},
// 无匹配返回原始模型
@@ -91,6 +95,7 @@ func TestMatchWildcardMapping(t *testing.T) {
},
requestedModel: "gemini-3-flash",
expected: "gemini-3-flash",
+ matched: false,
},
// 空映射返回原始模型
@@ -99,6 +104,7 @@ func TestMatchWildcardMapping(t *testing.T) {
mapping: map[string]string{},
requestedModel: "claude-sonnet-4-5",
expected: "claude-sonnet-4-5",
+ matched: false,
},
// Gemini 模型映射
@@ -110,14 +116,15 @@ func TestMatchWildcardMapping(t *testing.T) {
},
requestedModel: "gemini-3-flash-preview",
expected: "gemini-3-pro-high",
+ matched: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := matchWildcardMapping(tt.mapping, tt.requestedModel)
- if result != tt.expected {
- t.Errorf("matchWildcardMapping(%v, %q) = %q, want %q", tt.mapping, tt.requestedModel, result, tt.expected)
+ result, matched := matchWildcardMappingResult(tt.mapping, tt.requestedModel)
+ if result != tt.expected || matched != tt.matched {
+ t.Errorf("matchWildcardMappingResult(%v, %q) = (%q, %v), want (%q, %v)", tt.mapping, tt.requestedModel, result, matched, tt.expected, tt.matched)
}
})
}
@@ -268,6 +275,69 @@ func TestAccountGetMappedModel(t *testing.T) {
}
}
+func TestAccountResolveMappedModel(t *testing.T) {
+ tests := []struct {
+ name string
+ credentials map[string]any
+ requestedModel string
+ expectedModel string
+ expectedMatch bool
+ }{
+ {
+ name: "no mapping reports unmatched",
+ credentials: nil,
+ requestedModel: "gpt-5.4",
+ expectedModel: "gpt-5.4",
+ expectedMatch: false,
+ },
+ {
+ name: "exact passthrough mapping still counts as matched",
+ credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.4",
+ },
+ },
+ requestedModel: "gpt-5.4",
+ expectedModel: "gpt-5.4",
+ expectedMatch: true,
+ },
+ {
+ name: "wildcard passthrough mapping still counts as matched",
+ credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-*": "gpt-5.4",
+ },
+ },
+ requestedModel: "gpt-5.4",
+ expectedModel: "gpt-5.4",
+ expectedMatch: true,
+ },
+ {
+ name: "missing mapping reports unmatched",
+ credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5.2": "gpt-5.2",
+ },
+ },
+ requestedModel: "gpt-5.4",
+ expectedModel: "gpt-5.4",
+ expectedMatch: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ account := &Account{
+ Credentials: tt.credentials,
+ }
+ mappedModel, matched := account.ResolveMappedModel(tt.requestedModel)
+ if mappedModel != tt.expectedModel || matched != tt.expectedMatch {
+ t.Fatalf("ResolveMappedModel(%q) = (%q, %v), want (%q, %v)", tt.requestedModel, mappedModel, matched, tt.expectedModel, tt.expectedMatch)
+ }
+ })
+ }
+}
+
func TestAccountGetModelMapping_AntigravityEnsuresGeminiDefaultPassthroughs(t *testing.T) {
account := &Account{
Platform: PlatformAntigravity,
diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go
index 8fffce1b99..29f2b67215 100644
--- a/backend/internal/service/openai_codex_transform.go
+++ b/backend/internal/service/openai_codex_transform.go
@@ -339,8 +339,9 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
}
typ, _ := m["type"].(string)
- // 修复 OpenAI 上游的最新校验:"Expected an ID that begins with 'fc'"
- fixIDPrefix := func(id string) string {
+ // 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id;
+ // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。
+ fixCallIDPrefix := func(id string) string {
if id == "" || strings.HasPrefix(id, "fc") {
return id
}
@@ -358,8 +359,8 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
for key, value := range m {
newItem[key] = value
}
- if id, ok := newItem["id"].(string); ok && id != "" {
- newItem["id"] = fixIDPrefix(id)
+ if id, ok := newItem["id"].(string); ok && strings.HasPrefix(id, "call_") {
+ newItem["id"] = fixCallIDPrefix(id)
}
filtered = append(filtered, newItem)
continue
@@ -390,7 +391,7 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
}
if callID != "" {
- fixedCallID := fixIDPrefix(callID)
+ fixedCallID := fixCallIDPrefix(callID)
if fixedCallID != callID {
ensureCopy()
newItem["call_id"] = fixedCallID
@@ -404,14 +405,6 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
if !isCodexToolCallItemType(typ) {
delete(newItem, "call_id")
}
- } else {
- if id, ok := newItem["id"].(string); ok && id != "" {
- fixedID := fixIDPrefix(id)
- if fixedID != id {
- ensureCopy()
- newItem["id"] = fixedID
- }
- }
}
filtered = append(filtered, newItem)
diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go
index df012d7cd5..ae6f855594 100644
--- a/backend/internal/service/openai_codex_transform_test.go
+++ b/backend/internal/service/openai_codex_transform_test.go
@@ -33,12 +33,63 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
first, ok := input[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "item_reference", first["type"])
- require.Equal(t, "fc_ref1", first["id"])
+ require.Equal(t, "ref1", first["id"])
// 校验 input[1] 为 map,确保后续字段断言安全。
second, ok := input[1].(map[string]any)
require.True(t, ok)
- require.Equal(t, "fc_o1", second["id"])
+ require.Equal(t, "o1", second["id"])
+ require.Equal(t, "fc1", second["call_id"])
+}
+
+func TestApplyCodexOAuthTransform_ToolContinuationPreservesNativeMessageAndReasoningIDs(t *testing.T) {
+ reqBody := map[string]any{
+ "model": "gpt-5.2",
+ "input": []any{
+ map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
+ map[string]any{"type": "item_reference", "id": "rs_123"},
+ },
+ "tool_choice": "auto",
+ }
+
+ applyCodexOAuthTransform(reqBody, false, false)
+
+ input, ok := reqBody["input"].([]any)
+ require.True(t, ok)
+ require.Len(t, input, 2)
+
+ first, ok := input[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "msg_0", first["id"])
+
+ second, ok := input[1].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "rs_123", second["id"])
+}
+
+func TestApplyCodexOAuthTransform_ToolContinuationNormalizesToolReferenceIDsOnly(t *testing.T) {
+ reqBody := map[string]any{
+ "model": "gpt-5.2",
+ "input": []any{
+ map[string]any{"type": "item_reference", "id": "call_1"},
+ map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "ok"},
+ },
+ "tool_choice": "auto",
+ }
+
+ applyCodexOAuthTransform(reqBody, false, false)
+
+ input, ok := reqBody["input"].([]any)
+ require.True(t, ok)
+ require.Len(t, input, 2)
+
+ first, ok := input[0].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "fc1", first["id"])
+
+ second, ok := input[1].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "fc1", second["call_id"])
}
func TestApplyCodexOAuthTransform_ExplicitStoreFalsePreserved(t *testing.T) {
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index f893eeb93e..9529f6be46 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -51,10 +51,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
// 3. Model mapping
- mappedModel := account.GetMappedModel(originalModel)
- if mappedModel == originalModel && defaultMappedModel != "" {
- mappedModel = defaultMappedModel
- }
+ mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
responsesReq.Model = mappedModel
logger.L().Debug("openai chat_completions: model mapping applied",
diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go
index e4a3d9c01a..1e40ec6f42 100644
--- a/backend/internal/service/openai_gateway_messages.go
+++ b/backend/internal/service/openai_gateway_messages.go
@@ -59,11 +59,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
// 3. Model mapping
- mappedModel := account.GetMappedModel(originalModel)
- // 分组级降级:账号未映射时使用分组默认映射模型
- if mappedModel == originalModel && defaultMappedModel != "" {
- mappedModel = defaultMappedModel
- }
+ mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
responsesReq.Model = mappedModel
logger.L().Debug("openai messages: model mapping applied",
diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go
new file mode 100644
index 0000000000..9bf3fba3b9
--- /dev/null
+++ b/backend/internal/service/openai_model_mapping.go
@@ -0,0 +1,19 @@
+package service
+
+// resolveOpenAIForwardModel determines the upstream model for OpenAI-compatible
+// forwarding. Group-level default mapping only applies when the account itself
+// did not match any explicit model_mapping rule.
+func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedModel string) string {
+ if account == nil {
+ if defaultMappedModel != "" {
+ return defaultMappedModel
+ }
+ return requestedModel
+ }
+
+ mappedModel, matched := account.ResolveMappedModel(requestedModel)
+ if !matched && defaultMappedModel != "" {
+ return defaultMappedModel
+ }
+ return mappedModel
+}
diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go
new file mode 100644
index 0000000000..7af3ecae8f
--- /dev/null
+++ b/backend/internal/service/openai_model_mapping_test.go
@@ -0,0 +1,70 @@
+package service
+
+import "testing"
+
+func TestResolveOpenAIForwardModel(t *testing.T) {
+ tests := []struct {
+ name string
+ account *Account
+ requestedModel string
+ defaultMappedModel string
+ expectedModel string
+ }{
+ {
+ name: "falls back to group default when account has no mapping",
+ account: &Account{
+ Credentials: map[string]any{},
+ },
+ requestedModel: "gpt-5.4",
+ defaultMappedModel: "gpt-4o-mini",
+ expectedModel: "gpt-4o-mini",
+ },
+ {
+ name: "preserves exact passthrough mapping instead of group default",
+ account: &Account{
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.4",
+ },
+ },
+ },
+ requestedModel: "gpt-5.4",
+ defaultMappedModel: "gpt-4o-mini",
+ expectedModel: "gpt-5.4",
+ },
+ {
+ name: "preserves wildcard passthrough mapping instead of group default",
+ account: &Account{
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-*": "gpt-5.4",
+ },
+ },
+ },
+ requestedModel: "gpt-5.4",
+ defaultMappedModel: "gpt-4o-mini",
+ expectedModel: "gpt-5.4",
+ },
+ {
+ name: "uses account remap when explicit target differs",
+ account: &Account{
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5": "gpt-5.4",
+ },
+ },
+ },
+ requestedModel: "gpt-5",
+ defaultMappedModel: "gpt-4o-mini",
+ expectedModel: "gpt-5.4",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := resolveOpenAIForwardModel(tt.account, tt.requestedModel, tt.defaultMappedModel); got != tt.expectedModel {
+ t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", got, tt.expectedModel)
+ }
+ })
+ }
+}