From b9971ee3dfcc3f589209a1e35c0dca72fa9bea36 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:00:46 +0800 Subject: [PATCH] feat(sdk): add Home credential capabilities and quota APIs Use Home credential options as authoritative model capability configuration for compat handling and prompt cache behavior. Expose quota provider operations through the public plugin host API. --- .../executor/helps/model_capabilities.go | 2 +- .../executor/helps/model_capabilities_test.go | 12 + .../executor/openai_compat_executor.go | 27 ++ .../openai_compat_home_options_test.go | 108 ++++++++ .../auth/api_key_model_capabilities.go | 99 +++++++- sdk/cliproxy/auth/conductor_execution.go | 2 +- sdk/cliproxy/auth/conductor_home_execution.go | 2 +- .../auth/home_v8_model_capabilities_test.go | 236 ++++++++++++++++++ .../auth/home_web_search_capability_test.go | 2 +- sdk/pluginhost/quota.go | 61 +++++ sdk/pluginhost/quota_test.go | 60 +++++ 11 files changed, 597 insertions(+), 14 deletions(-) create mode 100644 internal/runtime/executor/openai_compat_home_options_test.go create mode 100644 sdk/cliproxy/auth/home_v8_model_capabilities_test.go create mode 100644 sdk/pluginhost/quota.go create mode 100644 sdk/pluginhost/quota_test.go 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) + } + } +}