From 1ecf0cb6020408f4311e503e2ea93b6f2fa9a900 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:08:39 +0800 Subject: [PATCH 1/4] fix(models): preserve home model capability metadata --- internal/api/server_routes.go | 65 +++++++++++++++------ internal/api/server_test.go | 20 ++++++- internal/client/codex/models/models.go | 30 +++++++++- internal/client/codex/models/models_test.go | 35 +++++++++++ 4 files changed, 129 insertions(+), 21 deletions(-) diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 815f6e3b..fc9de129 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -637,29 +637,39 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin models := make([]map[string]any, 0, len(entries)) for _, entry := range entries { - model := map[string]any{ - "id": entry.id, - "object": "model", - } - if entry.created > 0 { - model["created"] = entry.created - } - if entry.ownedBy != "" { - model["owned_by"] = entry.ownedBy - } - if entry.displayName != "" { - model["display_name"] = entry.displayName - model["description"] = entry.displayName - } - if entry.maxCompletionTokens > 0 { - model["max_completion_tokens"] = entry.maxCompletionTokens - } - models = append(models, model) + models = append(models, formatHomeCodexModel(entry)) } c.JSON(http.StatusOK, codexmodels.BuildResponseForClient(models, nil, s.cfg.Codex.OptimizeMultiAgentV2, clientVersion)) } +func formatHomeCodexModel(entry homeModelEntry) map[string]any { + model := map[string]any{ + "id": entry.id, + "object": "model", + } + if entry.created > 0 { + model["created"] = entry.created + } + if entry.ownedBy != "" { + model["owned_by"] = entry.ownedBy + } + if entry.displayName != "" { + model["display_name"] = entry.displayName + model["description"] = entry.displayName + } + if entry.contextLength > 0 { + model["context_length"] = entry.contextLength + } + if entry.maxCompletionTokens > 0 { + model["max_completion_tokens"] = entry.maxCompletionTokens + } + if entry.thinking != nil { + model["thinking"] = entry.thinking + } + return model +} + func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { return func(c *gin.Context) { if s != nil && s.cfg != nil && s.cfg.Home.Enabled { @@ -689,6 +699,7 @@ type homeModelEntry struct { displayName string contextLength int maxCompletionTokens int + thinking *registry.ThinkingSupport } func (s *Server) handleHomeModels(c *gin.Context) { @@ -984,6 +995,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { displayName, _ = model["displayName"].(string) displayName = strings.TrimSpace(displayName) } + thinking := homeModelThinkingSupport(model) out = append(out, homeModelEntry{ id: id, @@ -992,6 +1004,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { displayName: displayName, contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")), maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")), + thinking: thinking, }) } } @@ -1003,6 +1016,22 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { return out, nil } +func homeModelThinkingSupport(model map[string]any) *registry.ThinkingSupport { + raw, ok := model["thinking"] + if !ok || raw == nil { + return nil + } + data, errMarshal := json.Marshal(raw) + if errMarshal != nil { + return nil + } + var thinking registry.ThinkingSupport + if errUnmarshal := json.Unmarshal(data, &thinking); errUnmarshal != nil { + return nil + } + return &thinking +} + func homeModelInt64Value(model map[string]any, keys ...string) int64 { for _, key := range keys { switch value := model[key].(type) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3b8eab9b..0e90743f 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "sync" "sync/atomic" @@ -2497,7 +2498,13 @@ func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { { "name": "models/gemini-3-pro", "inputTokenLimit": 1048576, - "outputTokenLimit": 65536 + "outputTokenLimit": 65536, + "thinking": { + "min": 128, + "max": 65535, + "dynamic_allowed": true, + "levels": ["low", "medium", "high"] + } } ] }`)) @@ -2523,6 +2530,17 @@ func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { if geminiEntry.contextLength != 1048576 || geminiEntry.maxCompletionTokens != 65536 { t.Fatalf("gemini token metadata = %d/%d, want 1048576/65536", geminiEntry.contextLength, geminiEntry.maxCompletionTokens) } + if geminiEntry.thinking == nil || !reflect.DeepEqual(geminiEntry.thinking.Levels, []string{"low", "medium", "high"}) { + t.Fatalf("gemini thinking metadata = %#v, want low/medium/high", geminiEntry.thinking) + } + + formatted := formatHomeCodexModel(geminiEntry) + if got := homeModelInt64Value(formatted, "context_length"); got != 1048576 { + t.Fatalf("formatted Gemini context_length = %d, want 1048576", got) + } + if got, ok := formatted["thinking"].(*registry.ThinkingSupport); !ok || !reflect.DeepEqual(got.Levels, []string{"low", "medium", "high"}) { + t.Fatalf("formatted Gemini thinking metadata = %#v, want low/medium/high", formatted["thinking"]) + } } func TestHomeModelsAuthStatus(t *testing.T) { diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go index a21fc59d..7b807c67 100644 --- a/internal/client/codex/models/models.go +++ b/internal/client/codex/models/models.go @@ -251,6 +251,7 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st displayName := stringModelValue(model, "display_name") description := stringModelValue(model, "description") contextWindow := intModelValue(model, "context_length") + thinkingSupport := codexClientThinkingSupport(model) if info != nil { if info.DisplayName != "" { @@ -259,7 +260,7 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st if info.Description != "" { description = info.Description } - if info.ContextLength > 0 { + if contextWindow <= 0 && info.ContextLength > 0 { contextWindow = info.ContextLength } if info.Type == registry.OpenAIImageModelType { @@ -269,8 +270,11 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st } else { applyCodexClientInputModalitiesMetadata(entry, info.SupportedInputModalities) } - applyCodexClientThinkingMetadata(entry, info.Thinking, clientVersion) + if thinkingSupport == nil { + thinkingSupport = info.Thinking + } } + applyCodexClientThinkingMetadata(entry, thinkingSupport, clientVersion) if maxContextWindow := intModelValue(model, "max_context_length"); maxContextWindow > 0 { contextWindow = maxContextWindow @@ -308,6 +312,28 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st } } +func codexClientThinkingSupport(model map[string]any) *registry.ThinkingSupport { + raw, ok := model["thinking"] + if !ok || raw == nil { + return nil + } + switch thinking := raw.(type) { + case *registry.ThinkingSupport: + return thinking + case registry.ThinkingSupport: + return &thinking + } + data, errMarshal := json.Marshal(raw) + if errMarshal != nil { + return nil + } + var thinking registry.ThinkingSupport + if errUnmarshal := json.Unmarshal(data, &thinking); errUnmarshal != nil { + return nil + } + return &thinking +} + func applyCodexClientVisibilityOverride(entry map[string]any, id string) { switch strings.TrimSpace(id) { case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "grok-imagine-image", "grok-imagine-image-2.0", "grok-imagine-video", "grok-imagine-video-1.5", "grok-imagine-video-1.5-preview": diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go index 13994c28..a9029b17 100644 --- a/internal/client/codex/models/models_test.go +++ b/internal/client/codex/models/models_test.go @@ -458,3 +458,38 @@ func TestCodexClientModelsResponseMapsMaxCompletionTokensToMaxTokens(t *testing. } } } + +func TestCodexClientModelsResponseUsesProvidedCapabilitiesForNewHomeModel(t *testing.T) { + const modelID = "gemini-new-home-model-test" + const wantContextWindow = 1048576 + + resp := BuildResponse([]map[string]any{{ + "id": modelID, + "context_length": wantContextWindow, + "thinking": ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + }, + }}, nil, false) + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("models = %#v, want one model", resp["models"]) + } + model := models[0] + if got := intModelValue(model, "context_window"); got != wantContextWindow { + t.Fatalf("context_window = %d, want %d", got, wantContextWindow) + } + if got := intModelValue(model, "max_context_window"); got != wantContextWindow { + t.Fatalf("max_context_window = %d, want %d", got, wantContextWindow) + } + + rawLevels, ok := model["supported_reasoning_levels"].([]any) + if !ok || len(rawLevels) != 3 { + t.Fatalf("supported_reasoning_levels = %#v, want low/medium/high", model["supported_reasoning_levels"]) + } + for index, want := range []string{"low", "medium", "high"} { + level, ok := rawLevels[index].(map[string]any) + if !ok || stringModelValue(level, "effort") != want { + t.Fatalf("supported_reasoning_levels[%d] = %#v, want %q", index, rawLevels[index], want) + } + } +} -- 2.51.2 From 6ff680e90ab5c6d99f4538984f4b4ce57138a549 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:35:25 +0800 Subject: [PATCH 2/4] feat(auth): use home model capabilities for thinking --- .../executor/antigravity_executor_execute.go | 4 +- .../executor/antigravity_executor_stream.go | 2 +- .../executor/antigravity_executor_tokens.go | 2 +- ...ntigravity_home_model_capabilities_test.go | 99 +++++++++++++++++++ .../executor/helps/model_capabilities.go | 4 +- .../auth/api_key_model_capabilities.go | 24 ++++- sdk/cliproxy/auth/conductor_execution.go | 3 + sdk/cliproxy/auth/conductor_home.go | 46 +++++++-- sdk/cliproxy/auth/conductor_home_execution.go | 1 + sdk/cliproxy/auth/home_selection.go | 8 +- 10 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 internal/runtime/executor/antigravity_home_model_capabilities_test.go diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go index c016fbc8..948e75b2 100644 --- a/internal/runtime/executor/antigravity_executor_execute.go +++ b/internal/runtime/executor/antigravity_executor_execute.go @@ -70,7 +70,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au } originalTranslated, translated := helps.TranslateRequestPairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, false) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } @@ -221,7 +221,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * } originalTranslated, translated := helps.TranslateRequestPairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return resp, err } diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go index 34403794..d45b769c 100644 --- a/internal/runtime/executor/antigravity_executor_stream.go +++ b/internal/runtime/executor/antigravity_executor_stream.go @@ -65,7 +65,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya originalTranslated, translated := helps.TranslateRequestPairWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true) - translated, err = helps.ApplyThinkingWithSourcePayload(translated, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go index 80da62cc..e003b508 100644 --- a/internal/runtime/executor/antigravity_executor_tokens.go +++ b/internal/runtime/executor/antigravity_executor_tokens.go @@ -50,7 +50,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut // Prepare payload once (doesn't depend on baseURL) payload := helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, false) - payload, err := helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()) + payload, err := helps.ApplyRequestThinking(payload, req, opts, from.String(), to.String(), e.Identifier()) if err != nil { return cliproxyexecutor.Response{}, err } diff --git a/internal/runtime/executor/antigravity_home_model_capabilities_test.go b/internal/runtime/executor/antigravity_home_model_capabilities_test.go new file mode 100644 index 00000000..078ad923 --- /dev/null +++ b/internal/runtime/executor/antigravity_home_model_capabilities_test.go @@ -0,0 +1,99 @@ +package executor + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +type antigravityHomeModelCapabilityDispatcher struct { + baseURL string +} + +func (*antigravityHomeModelCapabilityDispatcher) HeartbeatOK() bool { return true } + +func (d *antigravityHomeModelCapabilityDispatcher) RPopAuth(context.Context, string, string, http.Header, int) ([]byte, error) { + return json.Marshal(map[string]any{ + "model": "gemini-3.8-flash-high", + "provider": "antigravity", + "model_info": map[string]any{ + "id": "gemini-3.8-flash-high", + "type": "gemini", + "context_length": 1048576, + "thinking": map[string]any{ + "levels": []string{"low", "medium", "high"}, + }, + "user_defined": false, + }, + "auth": cliproxyauth.Auth{ + ID: "home-antigravity-3.8-auth", + Provider: "antigravity", + Status: cliproxyauth.StatusActive, + Attributes: map[string]string{ + cliproxyauth.AttributeAuthKind: cliproxyauth.AuthKindOAuth, + "base_url": d.baseURL, + }, + Metadata: map[string]any{ + "access_token": "token", + "project_id": "project-1", + "expired": time.Now().Add(time.Hour).Format(time.RFC3339), + }, + }, + }) +} + +func (*antigravityHomeModelCapabilityDispatcher) AbortAmbiguousDispatch() {} + +func TestAntigravityHomeModelThinkingLevelRemainsLevel(t *testing.T) { + requestBodies := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + http.Error(w, errRead.Error(), http.StatusInternalServerError) + return + } + requestBodies <- body + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}}`) + })) + t.Cleanup(server.Close) + + cfg := &config.Config{RequestRetry: 1} + cfg.Home.Enabled = true + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.SetConfig(cfg) + manager.RegisterExecutor(NewAntigravityExecutor(cfg)) + manager.PublishHomeDispatch(&antigravityHomeModelCapabilityDispatcher{baseURL: server.URL}, executionregistry.New(), 1) + + payload := []byte(`{"model":"gemini-3.8-flash-high","reasoning_effort":"medium","messages":[{"role":"user","content":"hello"}]}`) + _, errExecute := manager.Execute(context.Background(), []string{"antigravity"}, cliproxyexecutor.Request{ + Model: "gemini-3.8-flash-high", + Payload: payload, + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FormatOpenAI, + ResponseFormat: sdktranslator.FormatOpenAI, + OriginalRequest: payload, + }) + if errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + + upstreamBody := <-requestBodies + if got := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "medium" { + t.Fatalf("thinkingLevel = %q, want medium; body=%s", got, upstreamBody) + } + if budget := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingBudget"); budget.Exists() { + t.Fatalf("thinkingBudget should be absent, got %s; body=%s", budget.Raw, upstreamBody) + } +} diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index e69c9a15..fa054fe7 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -14,14 +14,14 @@ func APIKeyModelIsCompat(req cliproxyexecutor.Request) bool { } // ApplyRequestThinking preserves the registry lookup path unless the auth -// manager bound an exact configured API-key model definition to this attempt. +// manager bound authoritative model capabilities to this execution attempt. func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string) ([]byte, error) { originalSource := opts.OriginalRequest if len(originalSource) == 0 { originalSource = req.Payload } summaryConfig := translatedRequestSummaryConfig(body, req.Payload, originalSource, req.Model, fromFormat, toFormat) - if modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req); ok { + if modelInfo, ok := cliproxyauth.ResolvedModelInfo(req); ok { return thinking.ApplyThinkingWithModelInfoAndSummary(body, originalSource, req.Model, fromFormat, toFormat, provider, modelInfo, summaryConfig) } return thinking.ApplyThinkingWithSummary(body, req.Model, fromFormat, toFormat, provider, summaryConfig) diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go index 8d4fb338..48e08ebe 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -11,7 +11,10 @@ import ( cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) -const resolvedAPIKeyModelInfoMetadataKey = "cliproxy.resolved_api_key_model_info" +const ( + resolvedAPIKeyModelInfoMetadataKey = "cliproxy.resolved_api_key_model_info" + resolvedHomeModelInfoMetadataKey = "cliproxy.resolved_home_model_info" +) type apiKeyModelCapabilityRoute struct { upstreamModel string @@ -57,6 +60,14 @@ func ResolvedAPIKeyModelInfo(req cliproxyexecutor.Request) (*registry.ModelInfo, return modelInfo, true } +// ResolvedModelInfo returns the authoritative model capabilities bound to this execution attempt. +func ResolvedModelInfo(req cliproxyexecutor.Request) (*registry.ModelInfo, bool) { + if modelInfo, ok := req.Metadata[resolvedHomeModelInfoMetadataKey].(*registry.ModelInfo); ok && modelInfo != nil { + return modelInfo, true + } + return ResolvedAPIKeyModelInfo(req) +} + // CodexAPIKeyModelIsCompat reports whether the selected codex-api-key model has // is-compat enabled. When true and codex.optimize-multi-agent-v2 is also true, // Codex MultiAgentV2 agent_message items are converted into portable Responses @@ -113,6 +124,17 @@ func attachResolvedAPIKeyModelInfo(routing *apiKeyModelRoutingSnapshot, req clip return req } +func attachResolvedHomeModelInfo(req cliproxyexecutor.Request, modelInfo *registry.ModelInfo) cliproxyexecutor.Request { + if modelInfo == nil { + return req + } + metadata := make(map[string]any, len(req.Metadata)+1) + maps.Copy(metadata, req.Metadata) + metadata[resolvedHomeModelInfoMetadataKey] = modelInfo + req.Metadata = metadata + return req +} + func lookupAPIKeyModelCapability(routing *apiKeyModelRoutingSnapshot, auth *Auth, routeModel, upstreamModel string) (*registry.ModelInfo, bool) { if !isConfiguredModelRoutingAuth(auth) || routing == nil { return nil, false diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index 7a778434..b746229f 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -1019,6 +1019,9 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string continue } execReq := sanitizeDownstreamWebsocketFallbackRequest(execCtx, auth, req) + if selection != nil && !restoreExecutionModel { + execReq = attachResolvedHomeModelInfo(execReq, selection.modelInfo) + } streamExecutionModel := "" if restoreExecutionModel { streamExecutionModel = executionModel diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go index a6389b82..5b4e2b43 100644 --- a/sdk/cliproxy/auth/conductor_home.go +++ b/sdk/cliproxy/auth/conductor_home.go @@ -13,6 +13,7 @@ import ( internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" log "github.com/sirupsen/logrus" @@ -343,14 +344,42 @@ func repeatedHomeAuthError() *Error { } type homeAuthDispatchResponse struct { - Model string `json:"model"` - Provider string `json:"provider"` - AuthIndex string `json:"auth_index"` - UserAPIKey string `json:"user_api_key"` - RequestRetry *int `json:"request_retry,omitempty"` - ForceMapping bool `json:"force_mapping"` - OriginalAlias string `json:"original_alias"` - Auth Auth `json:"auth"` + Model string `json:"model"` + Provider string `json:"provider"` + AuthIndex string `json:"auth_index"` + UserAPIKey string `json:"user_api_key"` + RequestRetry *int `json:"request_retry,omitempty"` + ForceMapping bool `json:"force_mapping"` + OriginalAlias string `json:"original_alias"` + ModelInfo *homeDispatchModelInfo `json:"model_info,omitempty"` + Auth Auth `json:"auth"` +} + +type homeDispatchModelInfo struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + InputTokenLimit int `json:"inputTokenLimit,omitempty"` + OutputTokenLimit int `json:"outputTokenLimit,omitempty"` + ContextLength int `json:"context_length,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + Thinking *registry.ThinkingSupport `json:"thinking,omitempty"` + UserDefined bool `json:"user_defined"` +} + +func (m *homeDispatchModelInfo) registryModelInfo() *registry.ModelInfo { + if m == nil || strings.TrimSpace(m.ID) == "" { + return nil + } + return ®istry.ModelInfo{ + ID: strings.TrimSpace(m.ID), + Type: strings.TrimSpace(m.Type), + InputTokenLimit: m.InputTokenLimit, + OutputTokenLimit: m.OutputTokenLimit, + ContextLength: m.ContextLength, + MaxCompletionTokens: m.MaxCompletionTokens, + Thinking: m.Thinking, + UserDefined: m.UserDefined, + } } type homeDispatchSessionHierarchyDispatcher interface { @@ -1157,6 +1186,7 @@ func (m *Manager) pickHomeDispatchSelection(ctx context.Context, model string, o endScope() return nil, &Error{Code: "home_unavailable", Message: "home execution registry unavailable", Retryable: true, HTTPStatus: http.StatusServiceUnavailable} } + selection.modelInfo = dispatch.ModelInfo.registryModelInfo() if pinnedAuthID == "" && dispatch.RequestRetry != nil && *dispatch.RequestRetry >= 0 { selection.requestRetry = *dispatch.RequestRetry selection.hasRequestRetry = true diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go index efd2e19d..2d5af936 100644 --- a/sdk/cliproxy/auth/conductor_home_execution.go +++ b/sdk/cliproxy/auth/conductor_home_execution.go @@ -187,6 +187,7 @@ func (m *Manager) executeHomeOnce(ctx context.Context, providers []string, req c } if !restoreExecutionModel { execReq = attachResolvedAPIKeyModelInfo(routing, execReq, preparedAuth, routeModel, upstreamModel) + execReq = attachResolvedHomeModelInfo(execReq, selection.modelInfo) } if errCtx := execCtx.Err(); errCtx != nil { releaseAttempt() diff --git a/sdk/cliproxy/auth/home_selection.go b/sdk/cliproxy/auth/home_selection.go index 815d6d36..d95d5518 100644 --- a/sdk/cliproxy/auth/home_selection.go +++ b/sdk/cliproxy/auth/home_selection.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" ) @@ -137,9 +138,10 @@ func (r *executionResources) Close() error { // HomeDispatchSelection keeps a Home execution scope separate from its auth. type HomeDispatchSelection struct { - Auth *Auth - Executor ProviderExecutor - Provider string + Auth *Auth + Executor ProviderExecutor + Provider string + modelInfo *registry.ModelInfo authMu sync.RWMutex scope *executionregistry.Scope -- 2.51.2 From c6dd82144baf2b9a387a8908a2448e9508a79f49 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:06:07 +0800 Subject: [PATCH 3/4] refactor(kimi): use request thinking helper --- internal/runtime/executor/kimi_executor.go | 4 +- .../kimi_home_model_capabilities_test.go | 131 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 internal/runtime/executor/kimi_home_model_capabilities_test.go diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go index 8bd73b9b..2232aed5 100644 --- a/internal/runtime/executor/kimi_executor.go +++ b/internal/runtime/executor/kimi_executor.go @@ -132,7 +132,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), "kimi", e.Identifier()) if err != nil { return resp, err } @@ -253,7 +253,7 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err) } - body, err = helps.ApplyThinkingWithSourcePayload(body, req.Payload, originalPayloadSource, req.Model, from.String(), "kimi", e.Identifier()) + body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), "kimi", e.Identifier()) if err != nil { return nil, err } diff --git a/internal/runtime/executor/kimi_home_model_capabilities_test.go b/internal/runtime/executor/kimi_home_model_capabilities_test.go new file mode 100644 index 00000000..05af7b64 --- /dev/null +++ b/internal/runtime/executor/kimi_home_model_capabilities_test.go @@ -0,0 +1,131 @@ +package executor + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executionregistry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" + "github.com/tidwall/gjson" +) + +type kimiHomeModelCapabilityDispatcher struct{} + +func (*kimiHomeModelCapabilityDispatcher) HeartbeatOK() bool { return true } + +func (*kimiHomeModelCapabilityDispatcher) RPopAuth(context.Context, string, string, http.Header, int) ([]byte, error) { + return json.Marshal(map[string]any{ + "model": "kimi-k3", + "provider": "kimi", + "model_info": map[string]any{ + "id": "kimi-k3", + "type": "kimi", + "context_length": 1048576, + "thinking": map[string]any{ + "levels": []string{"low", "high"}, + }, + "user_defined": false, + }, + "auth": cliproxyauth.Auth{ + ID: "home-kimi-k3-auth", + Provider: "kimi", + Status: cliproxyauth.StatusActive, + Attributes: map[string]string{}, + Metadata: map[string]any{ + "access_token": "token", + "expired": time.Now().Add(time.Hour).Format(time.RFC3339), + }, + }, + }) +} + +func (*kimiHomeModelCapabilityDispatcher) AbortAmbiguousDispatch() {} + +func TestKimiHomeModelThinkingCapabilitiesOverrideLocalRegistry(t *testing.T) { + tests := []struct { + name string + stream bool + }{ + {name: "non-streaming"}, + {name: "streaming", stream: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var upstreamBody []byte + ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) { + var errRead error + upstreamBody, errRead = io.ReadAll(req.Body) + if errRead != nil { + return nil, errRead + } + if tt.stream { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"id\":\"chatcmpl_test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"k3\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n" + + "data: [DONE]\n\n", + )), + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_test","object":"chat.completion","created":1,"model":"k3","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`, + )), + }, nil + })) + + cfg := &config.Config{RequestRetry: 1} + cfg.Home.Enabled = true + manager := cliproxyauth.NewManager(nil, nil, nil) + manager.SetConfig(cfg) + manager.RegisterExecutor(NewKimiExecutor(cfg)) + manager.PublishHomeDispatch(&kimiHomeModelCapabilityDispatcher{}, executionregistry.New(), 1) + + payload := []byte(`{"model":"kimi-k3","reasoning_effort":"max","messages":[{"role":"user","content":"hello"}]}`) + req := cliproxyexecutor.Request{Model: "kimi-k3", Payload: payload} + opts := cliproxyexecutor.Options{ + Stream: tt.stream, + SourceFormat: sdktranslator.FormatOpenAI, + ResponseFormat: sdktranslator.FormatOpenAI, + OriginalRequest: payload, + } + if tt.stream { + result, errExecute := manager.ExecuteStream(ctx, []string{"kimi"}, req, opts) + if errExecute != nil { + t.Fatalf("ExecuteStream() error = %v", errExecute) + } + for chunk := range result.Chunks { + if chunk.Err != nil { + t.Fatalf("stream chunk error = %v", chunk.Err) + } + } + } else { + if _, errExecute := manager.Execute(ctx, []string{"kimi"}, req, opts); errExecute != nil { + t.Fatalf("Execute() error = %v", errExecute) + } + } + + if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "enabled" { + t.Fatalf("thinking.type = %q, want enabled; body=%s", got, upstreamBody) + } + if got := gjson.GetBytes(upstreamBody, "thinking.effort").String(); got != "high" { + t.Fatalf("thinking.effort = %q, want high from Home model capabilities; body=%s", got, upstreamBody) + } + if effort := gjson.GetBytes(upstreamBody, "reasoning_effort"); effort.Exists() { + t.Fatalf("reasoning_effort should be absent, got %s; body=%s", effort.Raw, upstreamBody) + } + }) + } +} -- 2.51.2 From cdda333cd28703dce9cd31d5a57c9291c3171b86 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:25:56 +0800 Subject: [PATCH 4/4] fix(codex): clear unsupported reasoning levels --- internal/client/codex/models/models.go | 4 +- internal/client/codex/models/models_test.go | 53 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go index 7b807c67..8cf9310b 100644 --- a/internal/client/codex/models/models.go +++ b/internal/client/codex/models/models.go @@ -374,7 +374,7 @@ func applyCodexClientInputModalitiesMetadata(entry map[string]any, modalities [] } func applyCodexClientThinkingMetadata(entry map[string]any, thinking *registry.ThinkingSupport, clientVersion string) { - if thinking == nil || len(thinking.Levels) == 0 { + if thinking == nil { return } @@ -398,6 +398,8 @@ func applyCodexClientThinkingMetadata(entry map[string]any, thinking *registry.T }) } if len(levels) == 0 { + delete(entry, "supported_reasoning_levels") + delete(entry, "default_reasoning_level") return } if defaultLevel == "" { diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go index a9029b17..fe2852fc 100644 --- a/internal/client/codex/models/models_test.go +++ b/internal/client/codex/models/models_test.go @@ -493,3 +493,56 @@ func TestCodexClientModelsResponseUsesProvidedCapabilitiesForNewHomeModel(t *tes } } } + +func TestCodexClientModelsResponseDoesNotInheritUnsupportedReasoningLevels(t *testing.T) { + tests := []struct { + name string + version string + levels []string + wantEfforts []string + wantDefault string + }{ + {name: "modern client", version: "0.144.0", levels: []string{"max", "ultra"}, wantEfforts: []string{"max", "ultra"}, wantDefault: "max"}, + {name: "legacy client with no compatible level", version: "0.143.9", levels: []string{"max", "ultra"}}, + {name: "legacy client with one compatible level", version: "0.143.9", levels: []string{"high", "max"}, wantEfforts: []string{"high"}, wantDefault: "high"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := BuildResponseForClient([]map[string]any{{ + "id": "home-extended-reasoning-model-test", + "thinking": ®istry.ThinkingSupport{ + Levels: tt.levels, + }, + }}, nil, false, tt.version) + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("models = %#v, want one model", resp["models"]) + } + model := models[0] + if len(tt.wantEfforts) == 0 { + if _, exists := model["supported_reasoning_levels"]; exists { + t.Fatalf("supported_reasoning_levels = %#v, want absent", model["supported_reasoning_levels"]) + } + if _, exists := model["default_reasoning_level"]; exists { + t.Fatalf("default_reasoning_level = %#v, want absent", model["default_reasoning_level"]) + } + return + } + + rawLevels, ok := model["supported_reasoning_levels"].([]any) + if !ok || len(rawLevels) != len(tt.wantEfforts) { + t.Fatalf("supported_reasoning_levels = %#v, want %v", model["supported_reasoning_levels"], tt.wantEfforts) + } + for index, want := range tt.wantEfforts { + level, ok := rawLevels[index].(map[string]any) + if !ok || stringModelValue(level, "effort") != want { + t.Fatalf("supported_reasoning_levels[%d] = %#v, want %q", index, rawLevels[index], want) + } + } + if got := stringModelValue(model, "default_reasoning_level"); got != tt.wantDefault { + t.Fatalf("default_reasoning_level = %q, want %q", got, tt.wantDefault) + } + }) + } +} -- 2.51.2