mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-14 03:24:42 +08:00
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
108
internal/runtime/executor/openai_compat_home_options_test.go
Normal file
108
internal/runtime/executor/openai_compat_home_options_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
236
sdk/cliproxy/auth/home_v8_model_capabilities_test.go
Normal file
236
sdk/cliproxy/auth/home_v8_model_capabilities_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
61
sdk/pluginhost/quota.go
Normal file
61
sdk/pluginhost/quota.go
Normal file
@@ -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)
|
||||
}
|
||||
60
sdk/pluginhost/quota_test.go
Normal file
60
sdk/pluginhost/quota_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user