diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go index 7ff12487e..df2d06a68 100644 --- a/internal/runtime/executor/helps/model_capabilities.go +++ b/internal/runtime/executor/helps/model_capabilities.go @@ -9,7 +9,7 @@ import ( // APIKeyModelIsCompat reports whether the selected API-key model enables // compatibility handling for Claude thinking blocks. func APIKeyModelIsCompat(req cliproxyexecutor.Request) bool { - modelInfo, ok := cliproxyauth.ResolvedAPIKeyModelInfo(req) + modelInfo, ok := cliproxyauth.ResolvedModelInfo(req) return ok && modelInfo != nil && modelInfo.IsCompat } diff --git a/internal/runtime/executor/helps/model_capabilities_test.go b/internal/runtime/executor/helps/model_capabilities_test.go index 08e6bc4bd..7be026825 100644 --- a/internal/runtime/executor/helps/model_capabilities_test.go +++ b/internal/runtime/executor/helps/model_capabilities_test.go @@ -367,3 +367,15 @@ func TestApplyRequestThinkingUsesSelectedPrefixedAPIKeyModel(t *testing.T) { } assertResponse("stream", streamPayload) } + +func TestAPIKeyModelIsCompatUsesAuthoritativeHomeCapabilities(t *testing.T) { + for _, enabled := range []bool{false, true} { + req := cliproxyexecutor.Request{Metadata: map[string]any{ + "cliproxy.resolved_api_key_model_info": ®istry.ModelInfo{IsCompat: !enabled}, + "cliproxy.resolved_home_model_info": ®istry.ModelInfo{IsCompat: enabled}, + }} + if got := helps.APIKeyModelIsCompat(req); got != enabled { + t.Fatalf("Home compat=%v, got=%v", enabled, got) + } + } +} diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go index cab7b8e48..f33df2b65 100644 --- a/internal/runtime/executor/openai_compat_executor.go +++ b/internal/runtime/executor/openai_compat_executor.go @@ -940,6 +940,33 @@ func (e *OpenAICompatExecutor) resolveCompatConfig(auth *cliproxyauth.Auth) *con if auth == nil || e.cfg == nil { return nil } + if e.cfg.Home.Enabled { + // Home excludes provider credentials from the global configuration. These + // non-secret options belong to the credential selected for this attempt. + var options struct { + SupportPromptCacheKey bool `json:"support-prompt-cache-key"` + Models []config.OpenAICompatibilityModel `json:"models"` + } + present := false + if rawOptions, exists := auth.Metadata["credential_options"]; exists { + if data, errMarshal := json.Marshal(rawOptions); errMarshal == nil { + present = json.Unmarshal(data, &options) == nil + } + } + if raw, exists := auth.Attributes["support_prompt_cache_key"]; exists { + if value, errParse := strconv.ParseBool(raw); errParse == nil { + options.SupportPromptCacheKey = value + present = true + } + } + if present { + return &config.OpenAICompatibility{ + Name: auth.Attributes["compat_name"], + SupportPromptCacheKey: options.SupportPromptCacheKey, + Models: options.Models, + } + } + } if auth.AuthSourceKind() == cliproxyauth.AuthSourceConfig && auth.Attributes != nil { if rawIndex := strings.TrimSpace(auth.Attributes["config_index"]); rawIndex != "" { configIndex, errIndex := strconv.Atoi(rawIndex) diff --git a/internal/runtime/executor/openai_compat_home_options_test.go b/internal/runtime/executor/openai_compat_home_options_test.go new file mode 100644 index 000000000..c3628eef9 --- /dev/null +++ b/internal/runtime/executor/openai_compat_home_options_test.go @@ -0,0 +1,108 @@ +package executor + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + cpaauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth" + cpaexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator" + "github.com/tidwall/gjson" +) + +func TestHomeV8PromptCacheKey(t *testing.T) { + auth := &cpaauth.Auth{Provider: "compat", Attributes: map[string]string{"compat_name": "compat", "provider_key": "compat", "api_key": "fixture", "base_url": "https://example.test", "support_prompt_cache_key": "true"}, Metadata: map[string]any{"credential_options": map[string]any{"support-prompt-cache-key": true}}} + for _, home := range []bool{false, true} { + cfg := &config.Config{} + cfg.Home.Enabled = home + if !home { + cfg.OpenAICompatibility = []config.OpenAICompatibility{{Name: "compat", SupportPromptCacheKey: true}} + } + e := NewOpenAICompatExecutor("compat", cfg) + out, err := e.applyPromptCacheKey(context.Background(), auth, translator.FromString("openai"), "review-model", cpaexecutor.Request{Payload: []byte(`{"model":"review-model","prompt_cache_key":"fixture-cache-key"}`)}, cpaexecutor.Options{}, []byte(`{"model":"review-model"}`)) + if err != nil { + t.Fatal(err) + } + got := gjson.GetBytes(out, "prompt_cache_key").String() + t.Logf("home=%v prompt_cache_key=%q", home, got) + if got != "fixture-cache-key" { + t.Errorf("home=%v: enabled prompt cache option is not consumed", home) + } + } +} + +func TestHomeV8CompatOptionsReachUpstream(t *testing.T) { + bodies := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + bodies <- body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"fixture","choices":[{"message":{"role":"assistant","content":"ok"}}]}`)) + })) + defer server.Close() + cfg := &config.Config{} + cfg.Home.Enabled = true + e := NewOpenAICompatExecutor("home-compat", cfg) + for _, enabled := range []bool{true, false, true} { + auth := &cpaauth.Auth{Provider: "home-compat", Attributes: map[string]string{"compat_name": "home-compat", "api_key": "fixture", "base_url": server.URL + "/v1"}, Metadata: map[string]any{"credential_options": map[string]any{ + "support-prompt-cache-key": enabled, + "models": []any{map[string]any{"name": "upstream", "use-max-completion-tokens": enabled}}, + }}} + payload := []byte(`{"model":"upstream","input":[{"role":"user","content":"hi"}],"max_output_tokens":512,"prompt_cache_key":"fixture-cache"}`) + _, err := e.Execute(context.Background(), auth, cpaexecutor.Request{Model: "upstream", Payload: payload}, cpaexecutor.Options{SourceFormat: translator.FromString("openai-response")}) + if err != nil { + t.Fatal(err) + } + body := <-bodies + field := "max_tokens" + if enabled { + field = "max_completion_tokens" + } + if gjson.GetBytes(body, field).Int() != 512 { + t.Fatalf("enabled=%v missing %s in %s", enabled, field, body) + } + if enabled && gjson.GetBytes(body, "prompt_cache_key").String() != "fixture-cache" { + t.Fatalf("prompt cache missing: %s", body) + } + } +} + +func TestHomeV8PromptCacheOverrideAndFallback(t *testing.T) { + for _, tc := range []struct { + name string + home bool + attrs map[string]string + metadata map[string]any + global, want bool + }{ + {name: "explicit false", home: true, attrs: map[string]string{"support_prompt_cache_key": "false"}, metadata: map[string]any{"credential_options": map[string]any{"support-prompt-cache-key": true}}, global: true, want: false}, + {name: "older Home fallback", home: true, global: true, want: true}, + {name: "standalone ignores Home options", home: false, metadata: map[string]any{"credential_options": map[string]any{"support-prompt-cache-key": true}}, want: false}, + {name: "metadata only", home: true, metadata: map[string]any{"credential_options": map[string]any{"support-prompt-cache-key": true}}, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &config.Config{OpenAICompatibility: []config.OpenAICompatibility{{Name: "compat", SupportPromptCacheKey: tc.global}}} + cfg.Home.Enabled = tc.home + if tc.attrs == nil { + tc.attrs = map[string]string{} + } + tc.attrs["compat_name"] = "compat" + e := NewOpenAICompatExecutor("compat", cfg) + auth := &cpaauth.Auth{Provider: "compat", Attributes: tc.attrs, Metadata: tc.metadata} + result, err := e.applyPromptCacheKey(context.Background(), auth, translator.FromString("openai"), "model", cpaexecutor.Request{Payload: []byte(`{"prompt_cache_key":"cache"}`)}, cpaexecutor.Options{}, []byte(`{"model":"model"}`)) + if err != nil { + t.Fatal(err) + } + if got := gjson.GetBytes(result, "prompt_cache_key").Exists(); got != tc.want { + t.Fatalf("cache key present=%v, want=%v", got, tc.want) + } + }) + } +} diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go index bbc6f161a..2f819e960 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -1,6 +1,7 @@ package auth import ( + "encoding/json" "maps" "strings" @@ -81,6 +82,11 @@ func CodexAPIKeyModelIsCompat(cfg *internalconfig.Config, auth *Auth, model stri return false } entry := resolveCodexAPIKeyConfig(cfg, auth) + if cfg.Home.Enabled { + if compat, configured := homeAPIKeyModelIsCompat(auth, model, model); configured { + return compat + } + } if entry == nil || len(entry.Models) == 0 { return false } @@ -112,6 +118,70 @@ func CodexAPIKeyModelIsCompat(cfg *internalconfig.Config, auth *Auth, model stri return false } +// homeAPIKeyModelIsCompat reads the selected credential's model configuration. +// A present models list is authoritative, including empty lists and false defaults. +func homeAPIKeyModelIsCompat(auth *Auth, model, routeModel string) (bool, bool) { + if auth == nil { + return false, false + } + raw, exists := auth.Metadata["credential_options"] + if !exists { + return false, false + } + data, errMarshal := json.Marshal(raw) + if errMarshal != nil { + return false, false + } + var options struct { + Models json.RawMessage `json:"models"` + } + if errDecode := json.Unmarshal(data, &options); errDecode != nil || len(options.Models) == 0 { + return false, false + } + var models []internalconfig.CodexModel + if errDecode := json.Unmarshal(options.Models, &models); errDecode != nil { + return false, false + } + requested := strings.TrimSpace(model) + if requested == "" { + return false, true + } + baseModel := strings.TrimSpace(thinking.ParseSuffix(requested).ModelName) + _, routeCandidates := modelAliasLookupCandidates(rewriteModelForAuth(strings.TrimSpace(routeModel), auth)) + for _, route := range routeCandidates { + for _, candidate := range []string{requested, baseModel} { + for _, configured := range models { + name := strings.TrimSpace(configured.Name) + alias := strings.TrimSpace(configured.Alias) + if name == "" { + name = alias + } + if strings.EqualFold(name, candidate) && (strings.EqualFold(alias, route) || strings.EqualFold(name, route)) { + return configured.IsCompat, true + } + } + } + } + // Prefer upstream names over aliases and exact suffixes over base fallbacks. + for _, useAlias := range []bool{false, true} { + for _, candidate := range []string{requested, baseModel} { + if candidate == "" { + continue + } + for _, configured := range models { + name := strings.TrimSpace(configured.Name) + if useAlias || name == "" { + name = strings.TrimSpace(configured.Alias) + } + if strings.EqualFold(name, candidate) { + return configured.IsCompat, true + } + } + } + } + return false, true +} + func (m *Manager) attachResolvedAPIKeyModelInfo(req cliproxyexecutor.Request, auth *Auth, routeModel, upstreamModel string) cliproxyexecutor.Request { return attachResolvedAPIKeyModelInfo(m.loadAPIKeyModelRouting(), req, auth, routeModel, upstreamModel) } @@ -205,23 +275,32 @@ func lookupCodexOAuthModelCapability(auth *Auth, upstreamModel string) (*registr return nil, false } -func attachResolvedHomeModelInfo(req cliproxyexecutor.Request, modelInfo *registry.ModelInfo, support ...*bool) cliproxyexecutor.Request { +func attachResolvedHomeModelInfo(req cliproxyexecutor.Request, auth *Auth, routeModel string, modelInfo *registry.ModelInfo, support *bool) cliproxyexecutor.Request { if modelInfo == nil { return req } selected := *modelInfo - if len(support) > 0 && support[0] != nil { - selected.SupportConfigurationUpdate = *support[0] + + local, ok := ResolvedAPIKeyModelInfo(req) + if !ok { + local, ok = req.Metadata[resolvedCodexOAuthModelInfoMetadataKey].(*registry.ModelInfo) + } + sameModel := ok && local != nil && strings.EqualFold(strings.TrimSpace(thinking.ParseSuffix(local.ID).ModelName), strings.TrimSpace(thinking.ParseSuffix(selected.ID).ModelName)) + if support != nil { + selected.SupportConfigurationUpdate = *support } else { - selected.SupportConfigurationUpdate = false - local, ok := ResolvedAPIKeyModelInfo(req) - if !ok { - local, ok = req.Metadata[resolvedCodexOAuthModelInfoMetadataKey].(*registry.ModelInfo) - } - if ok && local != nil && strings.EqualFold(strings.TrimSpace(thinking.ParseSuffix(local.ID).ModelName), strings.TrimSpace(thinking.ParseSuffix(selected.ID).ModelName)) { - selected.SupportConfigurationUpdate = local.SupportConfigurationUpdate + selected.SupportConfigurationUpdate = sameModel && local.SupportConfigurationUpdate + } + upstreamModel := selected.ID + if auth != nil { + if dispatched := strings.TrimSpace(auth.Attributes[homeUpstreamModelAttributeKey]); dispatched != "" { + upstreamModel = dispatched } } + if compat, configured := homeAPIKeyModelIsCompat(auth, upstreamModel, routeModel); configured { + selected.IsCompat = compat + } + metadata := make(map[string]any, len(req.Metadata)+1) maps.Copy(metadata, req.Metadata) metadata[resolvedHomeModelInfoMetadataKey] = &selected diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index 135ed44c5..d84071d48 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -1115,7 +1115,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string } execReq := sanitizeDownstreamWebsocketFallbackRequest(execCtx, auth, req) if selection != nil && !restoreExecutionModel { - execReq = attachResolvedHomeModelInfo(execReq, selection.modelInfo, selection.configurationUpdateSupport) + execReq = attachResolvedHomeModelInfo(execReq, auth, routeModel, selection.modelInfo, selection.configurationUpdateSupport) } streamExecutionModel := "" if restoreExecutionModel { diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go index ad371e9b8..1b8f23387 100644 --- a/sdk/cliproxy/auth/conductor_home_execution.go +++ b/sdk/cliproxy/auth/conductor_home_execution.go @@ -200,7 +200,7 @@ func (m *Manager) executeHomeOnce(ctx context.Context, providers []string, req c } execReq = attachResolvedExecutionModelInfo(routing, execReq, preparedAuth, routeModel, upstreamModel, restoreExecutionModel) if !restoreExecutionModel { - execReq = attachResolvedHomeModelInfo(execReq, selection.modelInfo, selection.configurationUpdateSupport) + execReq = attachResolvedHomeModelInfo(execReq, preparedAuth, routeModel, selection.modelInfo, selection.configurationUpdateSupport) } if errCtx := execCtx.Err(); errCtx != nil { releaseAttempt() diff --git a/sdk/cliproxy/auth/home_v8_model_capabilities_test.go b/sdk/cliproxy/auth/home_v8_model_capabilities_test.go new file mode 100644 index 000000000..ac81aaaef --- /dev/null +++ b/sdk/cliproxy/auth/home_v8_model_capabilities_test.go @@ -0,0 +1,236 @@ +package auth + +import ( + "encoding/json" + "testing" + + internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor" +) + +func TestHomeV8ModelCapabilitiesAndLegacyPayload(t *testing.T) { + for _, tc := range []struct { + name, raw string + contextLength int + compat bool + }{ + {"v8", `{"id":"upstream","context_length":32768,"thinking":{"levels":["high","none"],"zero_allowed":true},"user_defined":false}`, 32768, true}, + {"v8-with-redundant-fields", `{"id":"upstream","context_length":32768,"max_context_length":32768,"is_compat":false,"thinking":{"levels":["high","none"],"zero_allowed":true},"user_defined":false}`, 32768, true}, + {"legacy", `{"id":"upstream","context_length":16384,"user_defined":true}`, 16384, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var wire homeDispatchModelInfo + if err := json.Unmarshal([]byte(tc.raw), &wire); err != nil { + t.Fatal(err) + } + auth := &Auth{} + if tc.compat { + auth.Metadata = map[string]any{"credential_options": map[string]any{"models": []internalconfig.CodexModel{{Name: "upstream", Alias: "alias", IsCompat: true}}}} + } + req := attachResolvedHomeModelInfo(cliproxyexecutor.Request{Model: "alias"}, auth, "alias", wire.registryModelInfo(), wire.SupportConfigurationUpdate) + info, ok := ResolvedModelInfo(req) + if !ok || info.MaxContextLength != 0 || info.IsCompat != tc.compat || info.ContextLength != tc.contextLength || info.UserDefined != wire.UserDefined { + t.Fatalf("resolved capabilities=%+v", info) + } + if tc.compat && (info.Thinking == nil || !info.Thinking.ZeroAllowed || len(info.Thinking.Levels) != 2) { + t.Fatalf("thinking=%+v", info.Thinking) + } + }) + } +} + +func TestHomeCodexCompatUsesSelectedCredentialOptions(t *testing.T) { + cfg := &internalconfig.Config{} + cfg.Home.Enabled = true + auth := &Auth{Provider: "codex", Metadata: map[string]any{"credential_options": map[string]any{"models": []any{map[string]any{"name": "upstream", "alias": "alias", "is-compat": true}}}}} + for _, model := range []string{"upstream", "alias", "upstream(high)"} { + if !CodexAPIKeyModelIsCompat(cfg, auth, model) { + t.Errorf("Home model %s lost compat flag", model) + } + } + if CodexAPIKeyModelIsCompat(cfg, &Auth{Provider: "codex"}, "upstream") { + t.Fatal("compat flag leaked to another credential") + } + cfg.Home.Enabled = false + if CodexAPIKeyModelIsCompat(cfg, auth, "upstream") { + t.Fatal("standalone began consuming Home options") + } +} + +func TestHomeCompatUsesCredentialModelOptions(t *testing.T) { + for _, tc := range []struct { + name, options, upstream, localModel, route string + want bool + }{ + {name: "explicit true", options: `{"models":[{"name":"model","is-compat":true}]}`, want: true}, + {name: "explicit false", options: `{"models":[{"name":"model","is-compat":false}]}`}, + {name: "omitted flag defaults false", options: `{"models":[{"name":"model"}]}`}, + {name: "null models", options: `{"models":null}`}, + {name: "empty models", options: `{"models":[]}`}, + {name: "unlisted model", options: `{"models":[{"name":"other","is-compat":true}]}`}, + {name: "missing options default false"}, + {name: "unrelated options default false", options: `{"weight":2}`}, + {name: "legacy different model", localModel: "other"}, + {name: "invalid models default false", options: `{"models":"invalid"}`}, + {name: "upstream wins over another alias", options: `{"models":[{"name":"other","alias":"model","is-compat":true},{"name":"model","is-compat":false}]}`}, + {name: "alias fallback", options: `{"models":[{"name":"upstream","alias":"model","is-compat":true}]}`, want: true}, + {name: "suffix fallback", options: `{"models":[{"name":"model","is-compat":true}]}`, upstream: "model(high)", want: true}, + {name: "exact dispatched suffix", options: `{"models":[{"name":"model","is-compat":true},{"name":"model(high)","is-compat":false}]}`, upstream: "model(high)"}, + {name: "same upstream with different aliases", options: `{"models":[{"name":"model","alias":"other","is-compat":false},{"name":"model","alias":"chosen","is-compat":true}]}`, upstream: "model(high)", route: "tenant/chosen(high)", want: true}, + {name: "same alias with different upstreams", options: `{"models":[{"name":"other","alias":"chosen","is-compat":false},{"name":"model","alias":"chosen","is-compat":true}]}`, route: "tenant/chosen", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + auth := &Auth{Prefix: "tenant", Attributes: map[string]string{homeUpstreamModelAttributeKey: tc.upstream}} + if tc.options != "" { + auth.Metadata = map[string]any{"credential_options": json.RawMessage(tc.options)} + } + localModel := tc.localModel + if localModel == "" { + localModel = "model" + } + local := ®istry.ModelInfo{ID: localModel, IsCompat: true} + homeInfo := ®istry.ModelInfo{ID: "model"} + req := cliproxyexecutor.Request{Model: "alias", Metadata: map[string]any{resolvedAPIKeyModelInfoMetadataKey: local}} + req = attachResolvedHomeModelInfo(req, auth, tc.route, homeInfo, nil) + info, ok := ResolvedModelInfo(req) + if !ok || info.IsCompat != tc.want { + t.Fatalf("resolved %+v, want compat=%v", info, tc.want) + } + if !local.IsCompat || homeInfo.IsCompat { + t.Fatal("resolution mutated the source model definitions") + } + }) + } +} + +func TestHomeCompatCredentialOptionsExecutionPaths(t *testing.T) { + for _, provider := range []string{"codex", "claude", "gemini", "custom-compat"} { + for _, path := range []string{"execute", "stream", "count"} { + t.Run(provider+"/"+path, func(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: true}}) + dispatcher := &configurationUpdateHomeDispatcher{} + manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1) + executor := &selectedCapabilityCaptureExecutor{provider: provider} + manager.RegisterExecutor(executor) + auth := configuredCapabilityTestAuth("home-compat-"+provider, "fixture-key") + auth.Provider = provider + for _, enabled := range []bool{true, false} { + auth.Metadata = map[string]any{"credential_options": map[string]any{"models": []internalconfig.CodexModel{ + {Name: "upstream", Alias: "other", IsCompat: !enabled}, + {Name: "upstream", Alias: "alias", IsCompat: enabled}, + }}} + payload, errMarshal := json.Marshal(homeAuthDispatchResponse{ + Model: "upstream(high)", Auth: *auth, ModelInfo: &homeDispatchModelInfo{ID: "upstream"}, + }) + if errMarshal != nil { + t.Fatal(errMarshal) + } + dispatcher.payload = payload + req := cliproxyexecutor.Request{Model: "tenant/alias(high)"} + opts := cliproxyexecutor.Options{} + switch path { + case "execute": + if _, errExecute := manager.Execute(t.Context(), []string{provider}, req, opts); errExecute != nil { + t.Fatal(errExecute) + } + case "stream": + stream, errStream := manager.ExecuteStream(t.Context(), []string{provider}, req, opts) + if errStream != nil { + t.Fatal(errStream) + } + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + } + case "count": + if _, errCount := manager.ExecuteCount(t.Context(), []string{provider}, req, opts); errCount != nil { + t.Fatal(errCount) + } + } + if len(executor.requests) == 0 { + t.Fatal("executor received no request") + } + info, ok := ResolvedModelInfo(executor.requests[len(executor.requests)-1]) + if !ok || info.IsCompat != enabled { + t.Fatalf("resolved capabilities=%+v, want compat=%v", info, enabled) + } + } + }) + } + } +} + +func TestHomeCompatDoesNotInheritLocalModelExecutionPaths(t *testing.T) { + for _, tc := range []struct { + name, options string + }{ + {name: "missing options"}, + {name: "unrelated options", options: `{"weight":2}`}, + {name: "invalid models", options: `{"models":"invalid"}`}, + } { + for _, path := range []string{"execute", "stream", "count"} { + t.Run(tc.name+"/"+path, func(t *testing.T) { + manager := NewManager(nil, nil, nil) + manager.SetConfig(&internalconfig.Config{ + Home: internalconfig.HomeConfig{Enabled: true}, + CodexKey: []internalconfig.CodexKey{{APIKey: "fixture-key", Prefix: "tenant", Models: []internalconfig.CodexModel{ + {Name: "upstream", Alias: "alias", IsCompat: true}, + }}}, + }) + auth := configuredCapabilityTestAuth("home-no-local-compat", "fixture-key") + auth.Provider = "codex" + auth.Attributes[AttributeSource] = "config:codex[0]" + if tc.options != "" { + auth.Metadata = map[string]any{"credential_options": json.RawMessage(tc.options)} + } + payload, errMarshal := json.Marshal(homeAuthDispatchResponse{ + Model: "upstream(high)", Auth: *auth, ModelInfo: &homeDispatchModelInfo{ID: "upstream"}, + }) + if errMarshal != nil { + t.Fatal(errMarshal) + } + manager.PublishHomeDispatch(configurationUpdateHomeDispatcher{payload: payload}, executionregistry.New(), 1) + executor := &selectedCapabilityCaptureExecutor{provider: "codex"} + manager.RegisterExecutor(executor) + req := cliproxyexecutor.Request{Model: "tenant/alias(high)"} + opts := cliproxyexecutor.Options{} + switch path { + case "execute": + if _, errExecute := manager.Execute(t.Context(), []string{"codex"}, req, opts); errExecute != nil { + t.Fatal(errExecute) + } + case "stream": + stream, errStream := manager.ExecuteStream(t.Context(), []string{"codex"}, req, opts) + if errStream != nil { + t.Fatal(errStream) + } + for chunk := range stream.Chunks { + if chunk.Err != nil { + t.Fatal(chunk.Err) + } + } + case "count": + if _, errCount := manager.ExecuteCount(t.Context(), []string{"codex"}, req, opts); errCount != nil { + t.Fatal(errCount) + } + } + if len(executor.requests) != 1 { + t.Fatalf("executor received %d requests, want 1", len(executor.requests)) + } + received := executor.requests[0] + local, okLocal := ResolvedAPIKeyModelInfo(received) + if !okLocal || !local.IsCompat { + t.Fatal("fixture did not bind the conflicting local compatibility setting") + } + info, ok := ResolvedModelInfo(received) + if !ok || info.IsCompat { + t.Fatalf("Home capabilities must not inherit local is-compat: %+v", info) + } + }) + } + } +} diff --git a/sdk/cliproxy/auth/home_web_search_capability_test.go b/sdk/cliproxy/auth/home_web_search_capability_test.go index ec2a6fd04..1ed8af11e 100644 --- a/sdk/cliproxy/auth/home_web_search_capability_test.go +++ b/sdk/cliproxy/auth/home_web_search_capability_test.go @@ -21,7 +21,7 @@ func TestHomeDispatchWebSearchCapability(t *testing.T) { if err := json.Unmarshal([]byte(tc.raw), &wire); err != nil { t.Fatal(err) } - req := attachResolvedHomeModelInfo(cliproxyexecutor.Request{Model: "alias"}, wire.registryModelInfo()) + req := attachResolvedHomeModelInfo(cliproxyexecutor.Request{Model: "alias"}, nil, "alias", wire.registryModelInfo(), wire.SupportConfigurationUpdate) info, ok := ResolvedModelInfo(req) if !ok || info.ID != "upstream" { t.Fatalf("missing resolved model: %+v", info) diff --git a/sdk/pluginhost/quota.go b/sdk/pluginhost/quota.go new file mode 100644 index 000000000..8c98905bb --- /dev/null +++ b/sdk/pluginhost/quota.go @@ -0,0 +1,61 @@ +package pluginhost + +import ( + "context" + + internalpluginhost "github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost" + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" +) + +// RegisteredQuotaProviderInfo describes quota capabilities exposed to embedders. +type RegisteredQuotaProviderInfo = internalpluginhost.RegisteredQuotaProviderInfo + +// QuotaProviders lists active quota providers using the request context. +func (h *Host) QuotaProviders(ctx context.Context) []RegisteredQuotaProviderInfo { + if h == nil || h.inner == nil { + return nil + } + return h.inner.QuotaProviders(ctx) +} + +// HasQuotaProviderContext reports whether a provider handles credential quotas. +func (h *Host) HasQuotaProviderContext(ctx context.Context, provider string) bool { + return h != nil && h.inner != nil && h.inner.HasQuotaProviderContext(ctx, provider) +} + +// HasQuotaProviderForPlugin reports whether a plugin exposes quota operations. +func (h *Host) HasQuotaProviderForPlugin(pluginID string) bool { + return h != nil && h.inner != nil && h.inner.HasQuotaProviderForPlugin(pluginID) +} + +// FetchQuota invokes a quota provider through the public embedding API. +func (h *Host) FetchQuota(ctx context.Context, req pluginapi.QuotaFetchRequest) (pluginapi.QuotaFetchResponse, bool, error) { + if h == nil || h.inner == nil { + return pluginapi.QuotaFetchResponse{}, false, nil + } + return h.inner.FetchQuota(ctx, req) +} + +// FetchQuotaByPlugin invokes a quota provider through the public embedding API. +func (h *Host) FetchQuotaByPlugin(ctx context.Context, pluginID string, req pluginapi.QuotaFetchRequest) (pluginapi.QuotaFetchResponse, bool, error) { + if h == nil || h.inner == nil { + return pluginapi.QuotaFetchResponse{}, false, nil + } + return h.inner.FetchQuotaByPlugin(ctx, pluginID, req) +} + +// ResetQuota invokes a quota provider through the public embedding API. +func (h *Host) ResetQuota(ctx context.Context, req pluginapi.QuotaResetRequest) (pluginapi.QuotaResetResponse, bool, error) { + if h == nil || h.inner == nil { + return pluginapi.QuotaResetResponse{}, false, nil + } + return h.inner.ResetQuota(ctx, req) +} + +// ResetQuotaByPlugin invokes a quota provider through the public embedding API. +func (h *Host) ResetQuotaByPlugin(ctx context.Context, pluginID string, req pluginapi.QuotaResetRequest) (pluginapi.QuotaResetResponse, bool, error) { + if h == nil || h.inner == nil { + return pluginapi.QuotaResetResponse{}, false, nil + } + return h.inner.ResetQuotaByPlugin(ctx, pluginID, req) +} diff --git a/sdk/pluginhost/quota_test.go b/sdk/pluginhost/quota_test.go new file mode 100644 index 000000000..fc28bd3f2 --- /dev/null +++ b/sdk/pluginhost/quota_test.go @@ -0,0 +1,60 @@ +package pluginhost + +import ( + "context" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi" +) + +type quotaSDKFixture struct{ t *testing.T } + +func (p quotaSDKFixture) Identifier() string { return "fixture" } +func (p quotaSDKFixture) DescribeQuota(context.Context, pluginapi.QuotaDescribeRequest) (pluginapi.QuotaDescribeResponse, error) { + return pluginapi.QuotaDescribeResponse{SupportedProviders: []string{"fixture"}, SupportsReset: true}, nil +} +func (p quotaSDKFixture) FetchQuota(_ context.Context, req pluginapi.QuotaFetchRequest) (pluginapi.QuotaFetchResponse, error) { + if req.AuthID != "credential" || req.Metadata["fixture"] != "value" { + p.t.Fatal("SDK dropped credential context") + } + return pluginapi.QuotaFetchResponse{Subscription: &pluginapi.QuotaSubscription{Plan: "fixture-plan"}}, nil +} +func (p quotaSDKFixture) ResetQuota(_ context.Context, req pluginapi.QuotaResetRequest) (pluginapi.QuotaResetResponse, error) { + if req.AuthID != "credential" { + p.t.Fatal("SDK dropped reset credential") + } + return pluginapi.QuotaResetResponse{Success: true}, nil +} + +func TestQuotaSDKEmbedding(t *testing.T) { + host := New() + host.inner.RegisterPluginForTest("fixture-plugin", pluginapi.Plugin{Capabilities: pluginapi.Capabilities{QuotaProvider: quotaSDKFixture{t}}}) + ctx := context.Background() + if len(host.QuotaProviders(ctx)) != 1 || !host.HasQuotaProviderContext(ctx, "fixture") || !host.HasQuotaProviderForPlugin("fixture-plugin") { + t.Fatal("missing public quota capabilities") + } + for _, byPlugin := range []bool{false, true} { + req := pluginapi.QuotaFetchRequest{Provider: "fixture", AuthID: "credential", Metadata: map[string]any{"fixture": "value"}} + var result pluginapi.QuotaFetchResponse + var handled bool + var err error + if byPlugin { + result, handled, err = host.FetchQuotaByPlugin(ctx, "fixture-plugin", req) + } else { + result, handled, err = host.FetchQuota(ctx, req) + } + if err != nil || !handled || result.Subscription == nil || result.Subscription.Plan != "fixture-plan" { + t.Fatalf("fetch: handled=%v err=%v", handled, err) + } + reset := pluginapi.QuotaResetRequest{Provider: "fixture", AuthID: "credential"} + var resetResult pluginapi.QuotaResetResponse + if byPlugin { + resetResult, handled, err = host.ResetQuotaByPlugin(ctx, "fixture-plugin", reset) + } else { + resetResult, handled, err = host.ResetQuota(ctx, reset) + } + if err != nil || !handled || !resetResult.Success { + t.Fatalf("reset: handled=%v err=%v", handled, err) + } + } +}