From fc329ebf37387512aa632b6c94dc9d81c1676fa7 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:12:28 +0800 Subject: [PATCH 01/16] docs(config): simplify oauth model alias example --- config.example.yaml | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 1c48e02d..612e4148 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -224,22 +224,10 @@ nonstream-keepalive-interval: 0 # Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi. # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode. # You can repeat the same name with different aliases to expose multiple client model names. -oauth-model-alias: - antigravity: - - name: "rev19-uic3-1p" - alias: "gemini-2.5-computer-use-preview-10-2025" - - name: "gemini-3-pro-image" - alias: "gemini-3-pro-image-preview" - - name: "gemini-3-pro-high" - alias: "gemini-3-pro-preview" - - name: "gemini-3-flash" - alias: "gemini-3-flash-preview" - - name: "claude-sonnet-4-5" - alias: "gemini-claude-sonnet-4-5" - - name: "claude-sonnet-4-5-thinking" - alias: "gemini-claude-sonnet-4-5-thinking" - - name: "claude-opus-4-5-thinking" - alias: "gemini-claude-opus-4-5-thinking" +# oauth-model-alias: +# antigravity: +# - name: "gemini-3-pro-high" +# alias: "gemini-3-pro-preview" # gemini-cli: # - name: "gemini-2.5-pro" # original model name under this channel # alias: "g2.5p" # client-visible alias From 896de027cc85a93d4522a76cc2fa14ebe535b5bd Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:13:54 +0800 Subject: [PATCH 02/16] docs(config): reorder antigravity model alias example --- config.example.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 612e4148..27668673 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -225,9 +225,6 @@ nonstream-keepalive-interval: 0 # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode. # You can repeat the same name with different aliases to expose multiple client model names. # oauth-model-alias: -# antigravity: -# - name: "gemini-3-pro-high" -# alias: "gemini-3-pro-preview" # gemini-cli: # - name: "gemini-2.5-pro" # original model name under this channel # alias: "g2.5p" # client-visible alias @@ -238,6 +235,9 @@ nonstream-keepalive-interval: 0 # aistudio: # - name: "gemini-2.5-pro" # alias: "g2.5p" +# antigravity: +# - name: "gemini-3-pro-high" +# alias: "gemini-3-pro-preview" # claude: # - name: "claude-sonnet-4-5-20250929" # alias: "cs4.5" From 0040d784964a0f71f883b2e176b3e753ba755532 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Tue, 10 Feb 2026 15:38:03 +0800 Subject: [PATCH 03/16] refactor(sdk): simplify provider lifecycle and registration logic --- cmd/server/main.go | 2 +- docs/sdk-access.md | 128 +++++------ docs/sdk-access_CN.md | 124 +++++----- internal/access/config_access/provider.go | 77 +++++-- internal/access/reconcile.go | 211 +++--------------- .../api/handlers/management/config_lists.go | 5 +- internal/api/server.go | 10 +- internal/config/config.go | 27 +-- internal/config/sdk_config.go | 65 ------ sdk/access/errors.go | 96 +++++++- sdk/access/manager.go | 21 +- sdk/access/registry.go | 94 ++++---- sdk/access/types.go | 47 ++++ sdk/cliproxy/builder.go | 8 +- sdk/config/config.go | 10 +- 15 files changed, 391 insertions(+), 534 deletions(-) create mode 100644 sdk/access/types.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 5bf4ba6a..dec30484 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -445,7 +445,7 @@ func main() { } // Register built-in access providers before constructing services. - configaccess.Register() + configaccess.Register(&cfg.SDKConfig) // Handle different command modes based on the provided flags. diff --git a/docs/sdk-access.md b/docs/sdk-access.md index e4e69629..343c851b 100644 --- a/docs/sdk-access.md +++ b/docs/sdk-access.md @@ -7,80 +7,71 @@ The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inb ```go import ( sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" ) ``` Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`. +## Provider Registry + +Providers are registered globally and then attached to a `Manager` as a snapshot: + +- `RegisterProvider(type, provider)` installs a pre-initialized provider instance. +- Registration order is preserved the first time each `type` is seen. +- `RegisteredProviders()` returns the providers in that order. + ## Manager Lifecycle ```go manager := sdkaccess.NewManager() -providers, err := sdkaccess.BuildProviders(cfg) -if err != nil { - return err -} -manager.SetProviders(providers) +manager.SetProviders(sdkaccess.RegisteredProviders()) ``` * `NewManager` constructs an empty manager. * `SetProviders` replaces the provider slice using a defensive copy. * `Providers` retrieves a snapshot that can be iterated safely from other goroutines. -* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider. + +If the manager itself is `nil` or no providers are configured, the call returns `nil, nil`, allowing callers to treat access control as disabled. ## Authenticating Requests ```go -result, err := manager.Authenticate(ctx, req) +result, authErr := manager.Authenticate(ctx, req) switch { -case err == nil: +case authErr == nil: // Authentication succeeded; result describes the provider and principal. -case errors.Is(err, sdkaccess.ErrNoCredentials): +case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials): // No recognizable credentials were supplied. -case errors.Is(err, sdkaccess.ErrInvalidCredential): +case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential): // Supplied credentials were present but rejected. default: - // Transport-level failure was returned by a provider. + // Internal/transport failure was returned by a provider. } ``` -`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting. - -If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors. +`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that return `AuthErrorCodeNotHandled`, and aggregates `AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` for a final result. Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential). -## Configuration Layout +## Built-in `config-api-key` Provider -The manager expects access providers under the `auth.providers` key inside `config.yaml`: +The proxy includes one built-in access provider: + +- `config-api-key`: Validates API keys declared under top-level `api-keys`. + - Credential sources: `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, `?key=`, `?auth_token=` + - Metadata: `Result.Metadata["source"]` is set to the matched source label. + +In the CLI server and `sdk/cliproxy`, this provider is registered automatically based on the loaded configuration. ```yaml -auth: - providers: - - name: inline-api - type: config-api-key - api-keys: - - sk-test-123 - - sk-prod-456 +api-keys: + - sk-test-123 + - sk-prod-456 ``` -Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options. +## Loading Providers from External Go Modules -### Loading providers from external SDK modules - -To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect: - -```yaml -auth: - providers: - - name: partner-auth - type: partner-token - sdk: github.com/acme/xplatform/sdk/access/providers/partner - config: - region: us-west-2 - audience: cli-proxy -``` +To consume a provider shipped in another Go module, import it for its registration side effect: ```go import ( @@ -89,19 +80,11 @@ import ( ) ``` -The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called. - -## Built-in Providers - -The SDK ships with one provider out of the box: - -- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found. - -Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`. +The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before you call `RegisteredProviders()` (or before `cliproxy.NewBuilder().Build()`). ### Metadata and auditing -`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing. +`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, `query-key`, `query-auth-token`). Populate this map in custom providers to enrich logs and downstream auditing. ## Writing Custom Providers @@ -110,13 +93,13 @@ type customProvider struct{} func (p *customProvider) Identifier() string { return "my-provider" } -func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) { +func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) { token := r.Header.Get("X-Custom") if token == "" { - return nil, sdkaccess.ErrNoCredentials + return nil, sdkaccess.NewNotHandledError() } if token != "expected" { - return nil, sdkaccess.ErrInvalidCredential + return nil, sdkaccess.NewInvalidCredentialError() } return &sdkaccess.Result{ Provider: p.Identifier(), @@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd } func init() { - sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) { - return &customProvider{}, nil - }) + sdkaccess.RegisterProvider("custom", &customProvider{}) } ``` -A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs. +A provider must implement `Identifier()` and `Authenticate()`. To make it available to the access manager, call `RegisterProvider` inside `init` with an initialized provider instance. ## Error Semantics -- `ErrNoCredentials`: no credentials were present or recognized by any provider. -- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them. -- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting. +- `NewNoCredentialsError()` (`AuthErrorCodeNoCredentials`): no credentials were present or recognized. (HTTP 401) +- `NewInvalidCredentialError()` (`AuthErrorCodeInvalidCredential`): credentials were present but rejected. (HTTP 401) +- `NewNotHandledError()` (`AuthErrorCodeNotHandled`): fall through to the next provider. +- `NewInternalAuthError(message, cause)` (`AuthErrorCodeInternal`): transport/system failure. (HTTP 500) -Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked. +Errors propagate immediately to the caller unless they are classified as `not_handled` / `no_credentials` / `invalid_credential` and can be aggregated by the manager. ## Integration with cliproxy Service -`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers: +`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a manager lets you reuse the same instance in your host process: ```go coreCfg, _ := config.LoadConfig("config.yaml") -providers, _ := sdkaccess.BuildProviders(coreCfg) -manager := sdkaccess.NewManager() -manager.SetProviders(providers) +accessManager := sdkaccess.NewManager() svc, _ := cliproxy.NewBuilder(). WithConfig(coreCfg). - WithAccessManager(manager). + WithConfigPath("config.yaml"). + WithRequestAccessManager(accessManager). Build() ``` -The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary. +Register any custom providers (typically via blank imports) before calling `Build()` so they are present in the global registry snapshot. -### Hot reloading providers +### Hot reloading -When configuration changes, rebuild providers and swap them into the manager: +When configuration changes, refresh any config-backed providers and then reset the manager's provider chain: ```go -providers, err := sdkaccess.BuildProviders(newCfg) -if err != nil { - log.Errorf("reload auth providers failed: %v", err) - return -} -accessManager.SetProviders(providers) +// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access +configaccess.Register(&newCfg.SDKConfig) +accessManager.SetProviders(sdkaccess.RegisteredProviders()) ``` -This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process. +This mirrors the behaviour in `internal/access.ApplyAccessProviders`, enabling runtime updates without restarting the process. diff --git a/docs/sdk-access_CN.md b/docs/sdk-access_CN.md index b3f26497..38aafe11 100644 --- a/docs/sdk-access_CN.md +++ b/docs/sdk-access_CN.md @@ -7,80 +7,71 @@ ```go import ( sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" ) ``` 通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。 +## Provider Registry + +访问提供者是全局注册,然后以快照形式挂到 `Manager` 上: + +- `RegisterProvider(type, provider)` 注册一个已经初始化好的 provider 实例。 +- 每个 `type` 第一次出现时会记录其注册顺序。 +- `RegisteredProviders()` 会按该顺序返回 provider 列表。 + ## 管理器生命周期 ```go manager := sdkaccess.NewManager() -providers, err := sdkaccess.BuildProviders(cfg) -if err != nil { - return err -} -manager.SetProviders(providers) +manager.SetProviders(sdkaccess.RegisteredProviders()) ``` - `NewManager` 创建空管理器。 - `SetProviders` 替换提供者切片并做防御性拷贝。 - `Providers` 返回适合并发读取的快照。 -- `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。 + +如果管理器本身为 `nil` 或未配置任何 provider,调用会返回 `nil, nil`,可视为关闭访问控制。 ## 认证请求 ```go -result, err := manager.Authenticate(ctx, req) +result, authErr := manager.Authenticate(ctx, req) switch { -case err == nil: +case authErr == nil: // Authentication succeeded; result carries provider and principal. -case errors.Is(err, sdkaccess.ErrNoCredentials): +case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials): // No recognizable credentials were supplied. -case errors.Is(err, sdkaccess.ErrInvalidCredential): +case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential): // Credentials were present but rejected. default: // Provider surfaced a transport-level failure. } ``` -`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。 - -若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。 +`Manager.Authenticate` 会按顺序遍历 provider:遇到成功立即返回,`AuthErrorCodeNotHandled` 会继续尝试下一个;`AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` 会在遍历结束后汇总给调用方。 `Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。 -## 配置结构 +## 内建 `config-api-key` Provider -在 `config.yaml` 的 `auth.providers` 下定义访问提供者: +代理内置一个访问提供者: + +- `config-api-key`:校验 `config.yaml` 顶层的 `api-keys`。 + - 凭证来源:`Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key`、`?key=`、`?auth_token=` + - 元数据:`Result.Metadata["source"]` 会写入匹配到的来源标识 + +在 CLI 服务端与 `sdk/cliproxy` 中,该 provider 会根据加载到的配置自动注册。 ```yaml -auth: - providers: - - name: inline-api - type: config-api-key - api-keys: - - sk-test-123 - - sk-prod-456 +api-keys: + - sk-test-123 + - sk-prod-456 ``` -条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。 +## 引入外部 Go 模块提供者 -### 引入外部 SDK 提供者 - -若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程: - -```yaml -auth: - providers: - - name: partner-auth - type: partner-token - sdk: github.com/acme/xplatform/sdk/access/providers/partner - config: - region: us-west-2 - audience: cli-proxy -``` +若要消费其它 Go 模块输出的访问提供者,直接用空白标识符导入以触发其 `init` 注册即可: ```go import ( @@ -89,19 +80,11 @@ import ( ) ``` -通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。 - -## 内建提供者 - -当前 SDK 默认内置: - -- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。 - -导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。 +空白导入可确保 `init` 先执行,从而在你调用 `RegisteredProviders()`(或 `cliproxy.NewBuilder().Build()`)之前完成 `sdkaccess.RegisterProvider`。 ### 元数据与审计 -`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key` 或 `query-key`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。 +`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization`、`x-goog-api-key`、`x-api-key`、`query-key`、`query-auth-token`)。自定义提供者同样可以填充该 Map,以便丰富日志与审计场景。 ## 编写自定义提供者 @@ -110,13 +93,13 @@ type customProvider struct{} func (p *customProvider) Identifier() string { return "my-provider" } -func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) { +func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) { token := r.Header.Get("X-Custom") if token == "" { - return nil, sdkaccess.ErrNoCredentials + return nil, sdkaccess.NewNotHandledError() } if token != "expected" { - return nil, sdkaccess.ErrInvalidCredential + return nil, sdkaccess.NewInvalidCredentialError() } return &sdkaccess.Result{ Provider: p.Identifier(), @@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd } func init() { - sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) { - return &customProvider{}, nil - }) + sdkaccess.RegisterProvider("custom", &customProvider{}) } ``` -自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。 +自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中用已初始化实例调用 `RegisterProvider` 注册到全局 registry。 ## 错误语义 -- `ErrNoCredentials`:任何提供者都未识别到凭证。 -- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。 -- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。 +- `NewNoCredentialsError()`(`AuthErrorCodeNoCredentials`):未提供或未识别到凭证。(HTTP 401) +- `NewInvalidCredentialError()`(`AuthErrorCodeInvalidCredential`):凭证存在但校验失败。(HTTP 401) +- `NewNotHandledError()`(`AuthErrorCodeNotHandled`):告诉管理器跳到下一个 provider。 +- `NewInternalAuthError(message, cause)`(`AuthErrorCodeInternal`):网络/系统错误。(HTTP 500) -自定义错误(例如网络异常)会马上冒泡返回。 +除可汇总的 `not_handled` / `no_credentials` / `invalid_credential` 外,其它错误会立即冒泡返回。 ## 与 cliproxy 集成 -使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器: +使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果希望在宿主进程里复用同一个 `Manager` 实例,可传入自定义管理器: ```go coreCfg, _ := config.LoadConfig("config.yaml") -providers, _ := sdkaccess.BuildProviders(coreCfg) -manager := sdkaccess.NewManager() -manager.SetProviders(providers) +accessManager := sdkaccess.NewManager() svc, _ := cliproxy.NewBuilder(). WithConfig(coreCfg). - WithAccessManager(manager). + WithConfigPath("config.yaml"). + WithRequestAccessManager(accessManager). Build() ``` -服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。 +请在调用 `Build()` 之前完成自定义 provider 的注册(通常通过空白导入触发 `init`),以确保它们被包含在全局 registry 的快照中。 ### 动态热更新提供者 -当配置发生变化时,可以重新构建提供者并替换当前列表: +当配置发生变化时,刷新依赖配置的 provider,然后重置 manager 的 provider 链: ```go -providers, err := sdkaccess.BuildProviders(newCfg) -if err != nil { - log.Errorf("reload auth providers failed: %v", err) - return -} -accessManager.SetProviders(providers) +// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access +configaccess.Register(&newCfg.SDKConfig) +accessManager.SetProviders(sdkaccess.RegisteredProviders()) ``` -这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。 +这一流程与 `internal/access.ApplyAccessProviders` 保持一致,避免为更新访问策略而重启进程。 diff --git a/internal/access/config_access/provider.go b/internal/access/config_access/provider.go index 70824524..84e8abcb 100644 --- a/internal/access/config_access/provider.go +++ b/internal/access/config_access/provider.go @@ -4,19 +4,28 @@ import ( "context" "net/http" "strings" - "sync" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" ) -var registerOnce sync.Once - // Register ensures the config-access provider is available to the access manager. -func Register() { - registerOnce.Do(func() { - sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider) - }) +func Register(cfg *sdkconfig.SDKConfig) { + if cfg == nil { + sdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey) + return + } + + keys := normalizeKeys(cfg.APIKeys) + if len(keys) == 0 { + sdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey) + return + } + + sdkaccess.RegisterProvider( + sdkaccess.AccessProviderTypeConfigAPIKey, + newProvider(sdkaccess.DefaultAccessProviderName, keys), + ) } type provider struct { @@ -24,34 +33,31 @@ type provider struct { keys map[string]struct{} } -func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) { - name := cfg.Name - if name == "" { - name = sdkconfig.DefaultAccessProviderName +func newProvider(name string, keys []string) *provider { + providerName := strings.TrimSpace(name) + if providerName == "" { + providerName = sdkaccess.DefaultAccessProviderName } - keys := make(map[string]struct{}, len(cfg.APIKeys)) - for _, key := range cfg.APIKeys { - if key == "" { - continue - } - keys[key] = struct{}{} + keySet := make(map[string]struct{}, len(keys)) + for _, key := range keys { + keySet[key] = struct{}{} } - return &provider{name: name, keys: keys}, nil + return &provider{name: providerName, keys: keySet} } func (p *provider) Identifier() string { if p == nil || p.name == "" { - return sdkconfig.DefaultAccessProviderName + return sdkaccess.DefaultAccessProviderName } return p.name } -func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) { +func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) { if p == nil { - return nil, sdkaccess.ErrNotHandled + return nil, sdkaccess.NewNotHandledError() } if len(p.keys) == 0 { - return nil, sdkaccess.ErrNotHandled + return nil, sdkaccess.NewNotHandledError() } authHeader := r.Header.Get("Authorization") authHeaderGoogle := r.Header.Get("X-Goog-Api-Key") @@ -63,7 +69,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess. queryAuthToken = r.URL.Query().Get("auth_token") } if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" { - return nil, sdkaccess.ErrNoCredentials + return nil, sdkaccess.NewNoCredentialsError() } apiKey := extractBearerToken(authHeader) @@ -94,7 +100,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess. } } - return nil, sdkaccess.ErrInvalidCredential + return nil, sdkaccess.NewInvalidCredentialError() } func extractBearerToken(header string) string { @@ -110,3 +116,26 @@ func extractBearerToken(header string) string { } return strings.TrimSpace(parts[1]) } + +func normalizeKeys(keys []string) []string { + if len(keys) == 0 { + return nil + } + normalized := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + continue + } + if _, exists := seen[trimmedKey]; exists { + continue + } + seen[trimmedKey] = struct{}{} + normalized = append(normalized, trimmedKey) + } + if len(normalized) == 0 { + return nil + } + return normalized +} diff --git a/internal/access/reconcile.go b/internal/access/reconcile.go index 267d2fe0..36601f99 100644 --- a/internal/access/reconcile.go +++ b/internal/access/reconcile.go @@ -6,9 +6,9 @@ import ( "sort" "strings" + configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" - sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" ) @@ -17,26 +17,26 @@ import ( // ordered provider slice along with the identifiers of providers that were added, updated, or // removed compared to the previous configuration. func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) { + _ = oldCfg if newCfg == nil { return nil, nil, nil, nil, nil } + result = sdkaccess.RegisteredProviders() + existingMap := make(map[string]sdkaccess.Provider, len(existing)) for _, provider := range existing { - if provider == nil { + providerID := identifierFromProvider(provider) + if providerID == "" { continue } - existingMap[provider.Identifier()] = provider + existingMap[providerID] = provider } - oldCfgMap := accessProviderMap(oldCfg) - newEntries := collectProviderEntries(newCfg) - - result = make([]sdkaccess.Provider, 0, len(newEntries)) - finalIDs := make(map[string]struct{}, len(newEntries)) + finalIDs := make(map[string]struct{}, len(result)) isInlineProvider := func(id string) bool { - return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName) + return strings.EqualFold(id, sdkaccess.DefaultAccessProviderName) } appendChange := func(list *[]string, id string) { if isInlineProvider(id) { @@ -45,85 +45,28 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov *list = append(*list, id) } - for _, providerCfg := range newEntries { - key := providerIdentifier(providerCfg) - if key == "" { + for _, provider := range result { + providerID := identifierFromProvider(provider) + if providerID == "" { continue } + finalIDs[providerID] = struct{}{} - forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey) - if oldCfgProvider, ok := oldCfgMap[key]; ok { - isAliased := oldCfgProvider == providerCfg - if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) { - if existingProvider, okExisting := existingMap[key]; okExisting { - result = append(result, existingProvider) - finalIDs[key] = struct{}{} - continue - } - } + existingProvider, exists := existingMap[providerID] + if !exists { + appendChange(&added, providerID) + continue } - - provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig) - if buildErr != nil { - return nil, nil, nil, nil, buildErr - } - if _, ok := oldCfgMap[key]; ok { - if _, existed := existingMap[key]; existed { - appendChange(&updated, key) - } else { - appendChange(&added, key) - } - } else { - appendChange(&added, key) - } - result = append(result, provider) - finalIDs[key] = struct{}{} - } - - if len(result) == 0 { - if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil { - key := providerIdentifier(inline) - if key != "" { - if oldCfgProvider, ok := oldCfgMap[key]; ok { - if providerConfigEqual(oldCfgProvider, inline) { - if existingProvider, okExisting := existingMap[key]; okExisting { - result = append(result, existingProvider) - finalIDs[key] = struct{}{} - goto inlineDone - } - } - } - provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig) - if buildErr != nil { - return nil, nil, nil, nil, buildErr - } - if _, existed := existingMap[key]; existed { - appendChange(&updated, key) - } else if _, hadOld := oldCfgMap[key]; hadOld { - appendChange(&updated, key) - } else { - appendChange(&added, key) - } - result = append(result, provider) - finalIDs[key] = struct{}{} - } - } - inlineDone: - } - - removedSet := make(map[string]struct{}) - for id := range existingMap { - if _, ok := finalIDs[id]; !ok { - if isInlineProvider(id) { - continue - } - removedSet[id] = struct{}{} + if !providerInstanceEqual(existingProvider, provider) { + appendChange(&updated, providerID) } } - removed = make([]string, 0, len(removedSet)) - for id := range removedSet { - removed = append(removed, id) + for providerID := range existingMap { + if _, exists := finalIDs[providerID]; exists { + continue + } + appendChange(&removed, providerID) } sort.Strings(added) @@ -142,6 +85,7 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con } existing := manager.Providers() + configaccess.Register(&newCfg.SDKConfig) providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing) if err != nil { log.Errorf("failed to reconcile request auth providers: %v", err) @@ -160,111 +104,24 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con return false, nil } -func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider { - result := make(map[string]*sdkConfig.AccessProvider) - if cfg == nil { - return result - } - for i := range cfg.Access.Providers { - providerCfg := &cfg.Access.Providers[i] - if providerCfg.Type == "" { - continue - } - key := providerIdentifier(providerCfg) - if key == "" { - continue - } - result[key] = providerCfg - } - if len(result) == 0 && len(cfg.APIKeys) > 0 { - if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil { - if key := providerIdentifier(provider); key != "" { - result[key] = provider - } - } - } - return result -} - -func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider { - entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers)) - for i := range cfg.Access.Providers { - providerCfg := &cfg.Access.Providers[i] - if providerCfg.Type == "" { - continue - } - if key := providerIdentifier(providerCfg); key != "" { - entries = append(entries, providerCfg) - } - } - if len(entries) == 0 && len(cfg.APIKeys) > 0 { - if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil { - entries = append(entries, inline) - } - } - return entries -} - -func providerIdentifier(provider *sdkConfig.AccessProvider) string { +func identifierFromProvider(provider sdkaccess.Provider) string { if provider == nil { return "" } - if name := strings.TrimSpace(provider.Name); name != "" { - return name - } - typ := strings.TrimSpace(provider.Type) - if typ == "" { - return "" - } - if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) { - return sdkConfig.DefaultAccessProviderName - } - return typ + return strings.TrimSpace(provider.Identifier()) } -func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool { +func providerInstanceEqual(a, b sdkaccess.Provider) bool { if a == nil || b == nil { return a == nil && b == nil } - if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) { + if reflect.TypeOf(a) != reflect.TypeOf(b) { return false } - if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) { - return false + valueA := reflect.ValueOf(a) + valueB := reflect.ValueOf(b) + if valueA.Kind() == reflect.Pointer && valueB.Kind() == reflect.Pointer { + return valueA.Pointer() == valueB.Pointer() } - if !stringSetEqual(a.APIKeys, b.APIKeys) { - return false - } - if len(a.Config) != len(b.Config) { - return false - } - if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) { - return false - } - return true -} - -func stringSetEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - seen := make(map[string]int, len(a)) - for _, val := range a { - seen[val]++ - } - for _, val := range b { - count := seen[val] - if count == 0 { - return false - } - if count == 1 { - delete(seen, val) - } else { - seen[val] = count - 1 - } - } - return len(seen) == 0 + return reflect.DeepEqual(a, b) } diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 4e0e0284..66e89992 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -109,14 +109,13 @@ func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.c func (h *Handler) PutAPIKeys(c *gin.Context) { h.putStringList(c, func(v []string) { h.cfg.APIKeys = append([]string(nil), v...) - h.cfg.Access.Providers = nil }, nil) } func (h *Handler) PatchAPIKeys(c *gin.Context) { - h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil }) + h.patchStringList(c, &h.cfg.APIKeys, func() {}) } func (h *Handler) DeleteAPIKeys(c *gin.Context) { - h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil }) + h.deleteFromStringList(c, &h.cfg.APIKeys, func() {}) } // gemini-api-key: []GeminiKey diff --git a/internal/api/server.go b/internal/api/server.go index 3eb09366..4cbcbba2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1033,14 +1033,10 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc { return } - switch { - case errors.Is(err, sdkaccess.ErrNoCredentials): - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"}) - case errors.Is(err, sdkaccess.ErrInvalidCredential): - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) - default: + statusCode := err.HTTPStatusCode() + if statusCode >= http.StatusInternalServerError { log.Errorf("authentication middleware error: %v", err) - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"}) } + c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message}) } } diff --git a/internal/config/config.go b/internal/config/config.go index fec58fe5..c78b2582 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -589,9 +589,6 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.ErrorLogsMaxFiles = 10 } - // Sync request authentication providers with inline API keys for backwards compatibility. - syncInlineAccessProvider(&cfg) - // Sanitize Gemini API key configuration and migrate legacy entries. cfg.SanitizeGeminiKeys() @@ -825,18 +822,6 @@ func normalizeModelPrefix(prefix string) string { return trimmed } -func syncInlineAccessProvider(cfg *Config) { - if cfg == nil { - return - } - if len(cfg.APIKeys) == 0 { - if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 { - cfg.APIKeys = append([]string(nil), provider.APIKeys...) - } - } - cfg.Access.Providers = nil -} - // looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash. func looksLikeBcrypt(s string) bool { return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$") @@ -924,7 +909,7 @@ func hashSecret(secret string) (string, error) { // SaveConfigPreserveComments writes the config back to YAML while preserving existing comments // and key ordering by loading the original file into a yaml.Node tree and updating values in-place. func SaveConfigPreserveComments(configFile string, cfg *Config) error { - persistCfg := sanitizeConfigForPersist(cfg) + persistCfg := cfg // Load original YAML as a node tree to preserve comments and ordering. data, err := os.ReadFile(configFile) if err != nil { @@ -992,16 +977,6 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error { return err } -func sanitizeConfigForPersist(cfg *Config) *Config { - if cfg == nil { - return nil - } - clone := *cfg - clone.SDKConfig = cfg.SDKConfig - clone.SDKConfig.Access = AccessConfig{} - return &clone -} - // SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"] // while preserving comments and positions. func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error { diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index 4d4abc37..5c3990a6 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -20,9 +20,6 @@ type SDKConfig struct { // APIKeys is a list of keys for authenticating clients to this proxy server. APIKeys []string `yaml:"api-keys" json:"api-keys"` - // Access holds request authentication provider configuration. - Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"` - // Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries). Streaming StreamingConfig `yaml:"streaming" json:"streaming"` @@ -42,65 +39,3 @@ type StreamingConfig struct { // <= 0 disables bootstrap retries. Default is 0. BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"` } - -// AccessConfig groups request authentication providers. -type AccessConfig struct { - // Providers lists configured authentication providers. - Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"` -} - -// AccessProvider describes a request authentication provider entry. -type AccessProvider struct { - // Name is the instance identifier for the provider. - Name string `yaml:"name" json:"name"` - - // Type selects the provider implementation registered via the SDK. - Type string `yaml:"type" json:"type"` - - // SDK optionally names a third-party SDK module providing this provider. - SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"` - - // APIKeys lists inline keys for providers that require them. - APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"` - - // Config passes provider-specific options to the implementation. - Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` -} - -const ( - // AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys. - AccessProviderTypeConfigAPIKey = "config-api-key" - - // DefaultAccessProviderName is applied when no provider name is supplied. - DefaultAccessProviderName = "config-inline" -) - -// ConfigAPIKeyProvider returns the first inline API key provider if present. -func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider { - if c == nil { - return nil - } - for i := range c.Access.Providers { - if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey { - if c.Access.Providers[i].Name == "" { - c.Access.Providers[i].Name = DefaultAccessProviderName - } - return &c.Access.Providers[i] - } - } - return nil -} - -// MakeInlineAPIKeyProvider constructs an inline API key provider configuration. -// It returns nil when no keys are supplied. -func MakeInlineAPIKeyProvider(keys []string) *AccessProvider { - if len(keys) == 0 { - return nil - } - provider := &AccessProvider{ - Name: DefaultAccessProviderName, - Type: AccessProviderTypeConfigAPIKey, - APIKeys: append([]string(nil), keys...), - } - return provider -} diff --git a/sdk/access/errors.go b/sdk/access/errors.go index 6ea2cc1a..6f344bb0 100644 --- a/sdk/access/errors.go +++ b/sdk/access/errors.go @@ -1,12 +1,90 @@ package access -import "errors" - -var ( - // ErrNoCredentials indicates no recognizable credentials were supplied. - ErrNoCredentials = errors.New("access: no credentials provided") - // ErrInvalidCredential signals that supplied credentials were rejected by a provider. - ErrInvalidCredential = errors.New("access: invalid credential") - // ErrNotHandled tells the manager to continue trying other providers. - ErrNotHandled = errors.New("access: not handled") +import ( + "fmt" + "net/http" + "strings" ) + +// AuthErrorCode classifies authentication failures. +type AuthErrorCode string + +const ( + AuthErrorCodeNoCredentials AuthErrorCode = "no_credentials" + AuthErrorCodeInvalidCredential AuthErrorCode = "invalid_credential" + AuthErrorCodeNotHandled AuthErrorCode = "not_handled" + AuthErrorCodeInternal AuthErrorCode = "internal_error" +) + +// AuthError carries authentication failure details and HTTP status. +type AuthError struct { + Code AuthErrorCode + Message string + StatusCode int + Cause error +} + +func (e *AuthError) Error() string { + if e == nil { + return "" + } + message := strings.TrimSpace(e.Message) + if message == "" { + message = "authentication error" + } + if e.Cause != nil { + return fmt.Sprintf("%s: %v", message, e.Cause) + } + return message +} + +func (e *AuthError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +// HTTPStatusCode returns a safe fallback for missing status codes. +func (e *AuthError) HTTPStatusCode() int { + if e == nil || e.StatusCode <= 0 { + return http.StatusInternalServerError + } + return e.StatusCode +} + +func newAuthError(code AuthErrorCode, message string, statusCode int, cause error) *AuthError { + return &AuthError{ + Code: code, + Message: message, + StatusCode: statusCode, + Cause: cause, + } +} + +func NewNoCredentialsError() *AuthError { + return newAuthError(AuthErrorCodeNoCredentials, "Missing API key", http.StatusUnauthorized, nil) +} + +func NewInvalidCredentialError() *AuthError { + return newAuthError(AuthErrorCodeInvalidCredential, "Invalid API key", http.StatusUnauthorized, nil) +} + +func NewNotHandledError() *AuthError { + return newAuthError(AuthErrorCodeNotHandled, "authentication provider did not handle request", 0, nil) +} + +func NewInternalAuthError(message string, cause error) *AuthError { + normalizedMessage := strings.TrimSpace(message) + if normalizedMessage == "" { + normalizedMessage = "Authentication service error" + } + return newAuthError(AuthErrorCodeInternal, normalizedMessage, http.StatusInternalServerError, cause) +} + +func IsAuthErrorCode(authErr *AuthError, code AuthErrorCode) bool { + if authErr == nil { + return false + } + return authErr.Code == code +} diff --git a/sdk/access/manager.go b/sdk/access/manager.go index fb5f8cca..2d4b0326 100644 --- a/sdk/access/manager.go +++ b/sdk/access/manager.go @@ -2,7 +2,6 @@ package access import ( "context" - "errors" "net/http" "sync" ) @@ -43,7 +42,7 @@ func (m *Manager) Providers() []Provider { } // Authenticate evaluates providers until one succeeds. -func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, error) { +func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError) { if m == nil { return nil, nil } @@ -61,29 +60,29 @@ func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, e if provider == nil { continue } - res, err := provider.Authenticate(ctx, r) - if err == nil { + res, authErr := provider.Authenticate(ctx, r) + if authErr == nil { return res, nil } - if errors.Is(err, ErrNotHandled) { + if IsAuthErrorCode(authErr, AuthErrorCodeNotHandled) { continue } - if errors.Is(err, ErrNoCredentials) { + if IsAuthErrorCode(authErr, AuthErrorCodeNoCredentials) { missing = true continue } - if errors.Is(err, ErrInvalidCredential) { + if IsAuthErrorCode(authErr, AuthErrorCodeInvalidCredential) { invalid = true continue } - return nil, err + return nil, authErr } if invalid { - return nil, ErrInvalidCredential + return nil, NewInvalidCredentialError() } if missing { - return nil, ErrNoCredentials + return nil, NewNoCredentialsError() } - return nil, ErrNoCredentials + return nil, NewNoCredentialsError() } diff --git a/sdk/access/registry.go b/sdk/access/registry.go index a29cdd96..cbb0d1c5 100644 --- a/sdk/access/registry.go +++ b/sdk/access/registry.go @@ -2,17 +2,15 @@ package access import ( "context" - "fmt" "net/http" + "strings" "sync" - - "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" ) // Provider validates credentials for incoming requests. type Provider interface { Identifier() string - Authenticate(ctx context.Context, r *http.Request) (*Result, error) + Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError) } // Result conveys authentication outcome. @@ -22,66 +20,64 @@ type Result struct { Metadata map[string]string } -// ProviderFactory builds a provider from configuration data. -type ProviderFactory func(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error) - var ( registryMu sync.RWMutex - registry = make(map[string]ProviderFactory) + registry = make(map[string]Provider) + order []string ) -// RegisterProvider registers a provider factory for a given type identifier. -func RegisterProvider(typ string, factory ProviderFactory) { - if typ == "" || factory == nil { +// RegisterProvider registers a pre-built provider instance for a given type identifier. +func RegisterProvider(typ string, provider Provider) { + normalizedType := strings.TrimSpace(typ) + if normalizedType == "" || provider == nil { return } + registryMu.Lock() - registry[typ] = factory + if _, exists := registry[normalizedType]; !exists { + order = append(order, normalizedType) + } + registry[normalizedType] = provider registryMu.Unlock() } -func BuildProvider(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error) { - if cfg == nil { - return nil, fmt.Errorf("access: nil provider config") +// UnregisterProvider removes a provider by type identifier. +func UnregisterProvider(typ string) { + normalizedType := strings.TrimSpace(typ) + if normalizedType == "" { + return } - registryMu.RLock() - factory, ok := registry[cfg.Type] - registryMu.RUnlock() - if !ok { - return nil, fmt.Errorf("access: provider type %q is not registered", cfg.Type) + registryMu.Lock() + if _, exists := registry[normalizedType]; !exists { + registryMu.Unlock() + return } - provider, err := factory(cfg, root) - if err != nil { - return nil, fmt.Errorf("access: failed to build provider %q: %w", cfg.Name, err) - } - return provider, nil -} - -// BuildProviders constructs providers declared in configuration. -func BuildProviders(root *config.SDKConfig) ([]Provider, error) { - if root == nil { - return nil, nil - } - providers := make([]Provider, 0, len(root.Access.Providers)) - for i := range root.Access.Providers { - providerCfg := &root.Access.Providers[i] - if providerCfg.Type == "" { + delete(registry, normalizedType) + for index := range order { + if order[index] != normalizedType { continue } - provider, err := BuildProvider(providerCfg, root) - if err != nil { - return nil, err + order = append(order[:index], order[index+1:]...) + break + } + registryMu.Unlock() +} + +// RegisteredProviders returns the global provider instances in registration order. +func RegisteredProviders() []Provider { + registryMu.RLock() + if len(order) == 0 { + registryMu.RUnlock() + return nil + } + providers := make([]Provider, 0, len(order)) + for _, providerType := range order { + provider, exists := registry[providerType] + if !exists || provider == nil { + continue } providers = append(providers, provider) } - if len(providers) == 0 { - if inline := config.MakeInlineAPIKeyProvider(root.APIKeys); inline != nil { - provider, err := BuildProvider(inline, root) - if err != nil { - return nil, err - } - providers = append(providers, provider) - } - } - return providers, nil + registryMu.RUnlock() + return providers } diff --git a/sdk/access/types.go b/sdk/access/types.go new file mode 100644 index 00000000..4ed80d04 --- /dev/null +++ b/sdk/access/types.go @@ -0,0 +1,47 @@ +package access + +// AccessConfig groups request authentication providers. +type AccessConfig struct { + // Providers lists configured authentication providers. + Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"` +} + +// AccessProvider describes a request authentication provider entry. +type AccessProvider struct { + // Name is the instance identifier for the provider. + Name string `yaml:"name" json:"name"` + + // Type selects the provider implementation registered via the SDK. + Type string `yaml:"type" json:"type"` + + // SDK optionally names a third-party SDK module providing this provider. + SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"` + + // APIKeys lists inline keys for providers that require them. + APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"` + + // Config passes provider-specific options to the implementation. + Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` +} + +const ( + // AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys. + AccessProviderTypeConfigAPIKey = "config-api-key" + + // DefaultAccessProviderName is applied when no provider name is supplied. + DefaultAccessProviderName = "config-inline" +) + +// MakeInlineAPIKeyProvider constructs an inline API key provider configuration. +// It returns nil when no keys are supplied. +func MakeInlineAPIKeyProvider(keys []string) *AccessProvider { + if len(keys) == 0 { + return nil + } + provider := &AccessProvider{ + Name: DefaultAccessProviderName, + Type: AccessProviderTypeConfigAPIKey, + APIKeys: append([]string(nil), keys...), + } + return provider +} diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go index 5eba18a0..60ca07f5 100644 --- a/sdk/cliproxy/builder.go +++ b/sdk/cliproxy/builder.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" + configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access" "github.com/router-for-me/CLIProxyAPI/v6/internal/api" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" @@ -186,11 +187,8 @@ func (b *Builder) Build() (*Service, error) { accessManager = sdkaccess.NewManager() } - providers, err := sdkaccess.BuildProviders(&b.cfg.SDKConfig) - if err != nil { - return nil, err - } - accessManager.SetProviders(providers) + configaccess.Register(&b.cfg.SDKConfig) + accessManager.SetProviders(sdkaccess.RegisteredProviders()) coreManager := b.coreManager if coreManager == nil { diff --git a/sdk/config/config.go b/sdk/config/config.go index a9b5c2c3..14163418 100644 --- a/sdk/config/config.go +++ b/sdk/config/config.go @@ -7,8 +7,6 @@ package config import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config" type SDKConfig = internalconfig.SDKConfig -type AccessConfig = internalconfig.AccessConfig -type AccessProvider = internalconfig.AccessProvider type Config = internalconfig.Config @@ -34,15 +32,9 @@ type OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel type TLS = internalconfig.TLSConfig const ( - AccessProviderTypeConfigAPIKey = internalconfig.AccessProviderTypeConfigAPIKey - DefaultAccessProviderName = internalconfig.DefaultAccessProviderName - DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository + DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository ) -func MakeInlineAPIKeyProvider(keys []string) *AccessProvider { - return internalconfig.MakeInlineAPIKeyProvider(keys) -} - func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) } func LoadConfigOptional(configFile string, optional bool) (*Config, error) { From 938a79926328a647f7dd33a28dabebb5cab5701a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:20:32 +0800 Subject: [PATCH 04/16] feat(translator): support Claude thinking type adaptive --- .../claude/antigravity_claude_request.go | 11 +- .../codex/claude/codex_claude_request.go | 4 + .../claude/gemini-cli_claude_request.go | 8 +- .../gemini/claude/gemini_claude_request.go | 8 +- .../openai/claude/openai_claude_request.go | 4 + test/thinking_conversion_test.go | 129 ++++++++++++++++++ 6 files changed, 160 insertions(+), 4 deletions(-) diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go index 69ed42e1..65ad2b19 100644 --- a/internal/translator/antigravity/claude/antigravity_claude_request.go +++ b/internal/translator/antigravity/claude/antigravity_claude_request.go @@ -344,7 +344,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Inject interleaved thinking hint when both tools and thinking are active hasTools := toolDeclCount > 0 thinkingResult := gjson.GetBytes(rawJSON, "thinking") - hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && thinkingResult.Get("type").String() == "enabled" + thinkingType := thinkingResult.Get("type").String() + hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == "enabled" || thinkingType == "adaptive") isClaudeThinking := util.IsClaudeThinkingModel(modelName) if hasTools && hasThinking && isClaudeThinking { @@ -377,12 +378,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() { - if t.Get("type").String() == "enabled" { + switch t.Get("type").String() { + case "enabled": if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } + case "adaptive": + // Keep adaptive as a high level sentinel; ApplyThinking resolves it + // to model-specific max capability. + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go index d7320717..223a2559 100644 --- a/internal/translator/codex/claude/codex_claude_request.go +++ b/internal/translator/codex/claude/codex_claude_request.go @@ -222,6 +222,10 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool) reasoningEffort = effort } } + case "adaptive": + // Claude adaptive means "enable with max capacity"; keep it as highest level + // and let ApplyThinking normalize per target model capability. + reasoningEffort = string(thinking.LevelXHigh) case "disabled": if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" { reasoningEffort = effort diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go index 657d33c8..ee661381 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_request.go @@ -173,12 +173,18 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) [] // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { - if t.Get("type").String() == "enabled" { + switch t.Get("type").String() { + case "enabled": if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget) out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } + case "adaptive": + // Keep adaptive as a high level sentinel; ApplyThinking resolves it + // to model-specific max capability. + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high") + out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index bab42952..e882f769 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -154,12 +154,18 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) // Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled // Translator only does format conversion, ApplyThinking handles model capability validation. if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() { - if t.Get("type").String() == "enabled" { + switch t.Get("type").String() { + case "enabled": if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number { budget := int(b.Int()) out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget) out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true) } + case "adaptive": + // Keep adaptive as a high level sentinel; ApplyThinking resolves it + // to model-specific max capability. + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingLevel", "high") + out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true) } } if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number { diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go index 1d9db94b..acb79a13 100644 --- a/internal/translator/openai/claude/openai_claude_request.go +++ b/internal/translator/openai/claude/openai_claude_request.go @@ -75,6 +75,10 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream out, _ = sjson.Set(out, "reasoning_effort", effort) } } + case "adaptive": + // Claude adaptive means "enable with max capacity"; keep it as highest level + // and let ApplyThinking normalize per target model capability. + out, _ = sjson.Set(out, "reasoning_effort", string(thinking.LevelXHigh)) case "disabled": if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" { out, _ = sjson.Set(out, "reasoning_effort", effort) diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go index 1f43777a..781a1667 100644 --- a/test/thinking_conversion_test.go +++ b/test/thinking_conversion_test.go @@ -2590,6 +2590,135 @@ func TestThinkingE2EMatrix_Body(t *testing.T) { runThinkingTests(t, cases) } +// TestThinkingE2EClaudeAdaptive_Body tests Claude thinking.type=adaptive extended body-only cases. +// These cases validate that adaptive means "thinking enabled without explicit budget", and +// cross-protocol conversion should resolve to target-model maximum thinking capability. +func TestThinkingE2EClaudeAdaptive_Body(t *testing.T) { + reg := registry.GetGlobalRegistry() + uid := fmt.Sprintf("thinking-e2e-claude-adaptive-%d", time.Now().UnixNano()) + + reg.RegisterClient(uid, "test", getTestModels()) + defer reg.UnregisterClient(uid) + + cases := []thinkingTestCase{ + // A1: Claude adaptive to OpenAI level model -> highest supported level + { + name: "A1", + from: "claude", + to: "openai", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "reasoning_effort", + expectValue: "high", + expectErr: false, + }, + // A2: Claude adaptive to Gemini level subset model -> highest supported level + { + name: "A2", + from: "claude", + to: "gemini", + model: "level-subset-model", + inputJSON: `{"model":"level-subset-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "high", + includeThoughts: "true", + expectErr: false, + }, + // A3: Claude adaptive to Gemini budget model -> max budget + { + name: "A3", + from: "claude", + to: "gemini", + model: "gemini-budget-model", + inputJSON: `{"model":"gemini-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // A4: Claude adaptive to Gemini mixed model -> highest supported level + { + name: "A4", + from: "claude", + to: "gemini", + model: "gemini-mixed-model", + inputJSON: `{"model":"gemini-mixed-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "generationConfig.thinkingConfig.thinkingLevel", + expectValue: "high", + includeThoughts: "true", + expectErr: false, + }, + // A5: Claude adaptive passthrough for same protocol + { + name: "A5", + from: "claude", + to: "claude", + model: "claude-budget-model", + inputJSON: `{"model":"claude-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "thinking.type", + expectValue: "adaptive", + expectErr: false, + }, + // A6: Claude adaptive to Antigravity budget model -> max budget + { + name: "A6", + from: "claude", + to: "antigravity", + model: "antigravity-budget-model", + inputJSON: `{"model":"antigravity-budget-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "request.generationConfig.thinkingConfig.thinkingBudget", + expectValue: "20000", + includeThoughts: "true", + expectErr: false, + }, + // A7: Claude adaptive to iFlow GLM -> enabled boolean + { + name: "A7", + from: "claude", + to: "iflow", + model: "glm-test", + inputJSON: `{"model":"glm-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "chat_template_kwargs.enable_thinking", + expectValue: "true", + expectErr: false, + }, + // A8: Claude adaptive to iFlow MiniMax -> enabled boolean + { + name: "A8", + from: "claude", + to: "iflow", + model: "minimax-test", + inputJSON: `{"model":"minimax-test","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "reasoning_split", + expectValue: "true", + expectErr: false, + }, + // A9: Claude adaptive to Codex level model -> highest supported level + { + name: "A9", + from: "claude", + to: "codex", + model: "level-model", + inputJSON: `{"model":"level-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "reasoning.effort", + expectValue: "high", + expectErr: false, + }, + // A10: Claude adaptive on non-thinking model should still be stripped + { + name: "A10", + from: "claude", + to: "openai", + model: "no-thinking-model", + inputJSON: `{"model":"no-thinking-model","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"}}`, + expectField: "", + expectErr: false, + }, + } + + runThinkingTests(t, cases) +} + // getTestModels returns the shared model definitions for E2E tests. func getTestModels() []*registry.ModelInfo { return []*registry.ModelInfo{ From 2b97cb98b586d1bd4d9d9496205a9a40394f1018 Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 17:35:54 +0900 Subject: [PATCH 05/16] Delete 'user' field from raw JSON Remove the 'user' field from the raw JSON as requested. --- .../codex/openai/responses/codex_openai-responses_request.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go index 828c4d87..692cfaa6 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -27,6 +27,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") + // Delete user field as requested + rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") + // Convert role "system" to "developer" in input array to comply with Codex API requirements. rawJSON = convertSystemRoleToDeveloper(rawJSON) From 865af9f19ea90c2684b8e1703732a3451932f679 Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 17:38:49 +0900 Subject: [PATCH 06/16] Implement test for user field deletion Add test to verify deletion of user field in response --- .../codex_openai-responses_request_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index ea413238..2d1d47a1 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -263,3 +263,20 @@ func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) { t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String()) } } + +func TestUserFieldDeletion(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "user": "test-user", + "input": [{"role": "user", "content": "Hello"}] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Verify user field is deleted + userField := gjson.Get(outputStr, "user") + if userField.Exists() { + t.Error("user field should be deleted") + } +} From afe4c1bfb7dfd2d0259ebc306e098c2cff33038d Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 18:24:26 +0900 Subject: [PATCH 07/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0internal/translator/cod?= =?UTF-8?q?ex/openai/responses/codex=5Fopenai-responses=5Frequest.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../codex/openai/responses/codex_openai-responses_request.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go index 692cfaa6..f0407149 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -27,8 +27,8 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") - // Delete user field as requested - rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") + // Delete the user field as it is not supported by the Codex upstream. + rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") // Convert role "system" to "developer" in input array to comply with Codex API requirements. rawJSON = convertSystemRoleToDeveloper(rawJSON) From bb9fe52f1e8aa592fd7a5b3c40bd9dd1b8f7c38d Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 18:24:58 +0900 Subject: [PATCH 08/16] Update internal/translator/codex/openai/responses/codex_openai-responses_request_test.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../openai/responses/codex_openai-responses_request_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index 2d1d47a1..4f562486 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -276,7 +276,7 @@ func TestUserFieldDeletion(t *testing.T) { // Verify user field is deleted userField := gjson.Get(outputStr, "user") - if userField.Exists() { - t.Error("user field should be deleted") - } + if userField.Exists() { + t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw) + } } From 349ddcaa894367648c050e7b0f0c2e66ae7e3220 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:05:40 +0800 Subject: [PATCH 09/16] fix(registry): correct max completion tokens for opus 4.6 thinking --- internal/registry/model_definitions_static_data.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/registry/model_definitions_static_data.go b/internal/registry/model_definitions_static_data.go index 44c4133e..bd7d74a4 100644 --- a/internal/registry/model_definitions_static_data.go +++ b/internal/registry/model_definitions_static_data.go @@ -866,7 +866,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig { "gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}}, "claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, - "claude-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 128000}, + "claude-opus-4-6-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 128000, ZeroAllowed: true, DynamicAllowed: true}, MaxCompletionTokens: 64000}, "claude-sonnet-4-5": {MaxCompletionTokens: 64000}, "gpt-oss-120b-medium": {}, "tab_flash_lite_preview": {}, From 1510bfcb6f1c5e8759995c204b35f034e49d467f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 11 Feb 2026 15:04:01 +0800 Subject: [PATCH 10/16] fix(translator): improve content handling for system and user messages - Added support for single and array-based `content` cases. - Enhanced `system_instruction` structure population logic. - Improved handling of user role assignment for string-based `content`. --- .../gemini_openai-responses_request.go | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index 1ddb1f36..aca01717 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -117,19 +117,29 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte switch itemType { case "message": if strings.EqualFold(itemRole, "system") { - if contentArray := item.Get("content"); contentArray.Exists() && contentArray.IsArray() { - var builder strings.Builder - contentArray.ForEach(func(_, contentItem gjson.Result) bool { - text := contentItem.Get("text").String() - if builder.Len() > 0 && text != "" { - builder.WriteByte('\n') - } - builder.WriteString(text) - return true - }) - if !gjson.Get(out, "system_instruction").Exists() { - systemInstr := `{"parts":[{"text":""}]}` - systemInstr, _ = sjson.Set(systemInstr, "parts.0.text", builder.String()) + if contentArray := item.Get("content"); contentArray.Exists() { + systemInstr := "" + if systemInstructionResult := gjson.Get(out, "system_instruction"); systemInstructionResult.Exists() { + systemInstr = systemInstructionResult.Raw + } else { + systemInstr = `{"parts":[]}` + } + + if contentArray.IsArray() { + contentArray.ForEach(func(_, contentItem gjson.Result) bool { + part := `{"text":""}` + text := contentItem.Get("text").String() + part, _ = sjson.Set(part, "text", text) + systemInstr, _ = sjson.SetRaw(systemInstr, "parts.-1", part) + return true + }) + } else if contentArray.Type == gjson.String { + part := `{"text":""}` + part, _ = sjson.Set(part, "text", contentArray.String()) + systemInstr, _ = sjson.SetRaw(systemInstr, "parts.-1", part) + } + + if systemInstr != `{"parts":[]}` { out, _ = sjson.SetRaw(out, "system_instruction", systemInstr) } } @@ -236,8 +246,22 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte }) flush() - } + } else if contentArray.Type == gjson.String { + effRole := "user" + if itemRole != "" { + switch strings.ToLower(itemRole) { + case "assistant", "model": + effRole = "model" + default: + effRole = strings.ToLower(itemRole) + } + } + one := `{"role":"","parts":[{"text":""}]}` + one, _ = sjson.Set(one, "role", effRole) + one, _ = sjson.Set(one, "parts.0.text", contentArray.String()) + out, _ = sjson.SetRaw(out, "contents.-1", one) + } case "function_call": // Handle function calls - convert to model message with functionCall name := item.Get("name").String() From 5ed2133ff9a96f5e51796ed2df6867a494a01bea Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:21:12 +0800 Subject: [PATCH 11/16] feat: add per-account excluded_models and priority parsing --- internal/watcher/synthesizer/file.go | 61 +++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index c80ebc66..20b2faec 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -92,6 +93,9 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e status = coreauth.StatusDisabled } + // Read per-account excluded models from the OAuth JSON file + perAccountExcluded := extractExcludedModelsFromMetadata(metadata) + a := &coreauth.Auth{ ID: id, Provider: provider, @@ -108,11 +112,22 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e CreatedAt: now, UpdatedAt: now, } - ApplyAuthExcludedModelsMeta(a, cfg, nil, "oauth") + // Read priority from auth file + if rawPriority, ok := metadata["priority"]; ok { + switch v := rawPriority.(type) { + case float64: + a.Attributes["priority"] = strconv.Itoa(int(v)) + case string: + if _, err := strconv.Atoi(v); err == nil { + a.Attributes["priority"] = v + } + } + } + ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth") if provider == "gemini-cli" { if virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 { for _, v := range virtuals { - ApplyAuthExcludedModelsMeta(v, cfg, nil, "oauth") + ApplyAuthExcludedModelsMeta(v, cfg, perAccountExcluded, "oauth") } out = append(out, a) out = append(out, virtuals...) @@ -167,6 +182,10 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an if authPath != "" { attrs["path"] = authPath } + // Propagate priority from primary auth to virtual auths + if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" { + attrs["priority"] = priorityVal + } metadataCopy := map[string]any{ "email": email, "project_id": projectID, @@ -239,3 +258,41 @@ func buildGeminiVirtualID(baseID, projectID string) string { replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_") return fmt.Sprintf("%s::%s", baseID, replacer.Replace(project)) } + +// extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata. +// Supports both "excluded_models" and "excluded-models" keys, and accepts both []string and []interface{}. +func extractExcludedModelsFromMetadata(metadata map[string]any) []string { + if metadata == nil { + return nil + } + // Try both key formats + raw, ok := metadata["excluded_models"] + if !ok { + raw, ok = metadata["excluded-models"] + } + if !ok || raw == nil { + return nil + } + switch v := raw.(type) { + case []string: + result := make([]string, 0, len(v)) + for _, s := range v { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + return result + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + } + return result + default: + return nil + } +} From b93026d83a8da573f4871c8a483287d2ea8c02d6 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:21:15 +0800 Subject: [PATCH 12/16] feat: merge per-account excluded_models with global config --- internal/watcher/synthesizer/helpers.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go index 621f3600..102dc77e 100644 --- a/internal/watcher/synthesizer/helpers.go +++ b/internal/watcher/synthesizer/helpers.go @@ -53,6 +53,8 @@ func (g *StableIDGenerator) Next(kind string, parts ...string) (string, string) // ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry. // It computes a hash of excluded models and sets the auth_kind attribute. +// For OAuth entries, perKey (from the JSON file's excluded-models field) is merged +// with the global oauth-excluded-models config for the provider. func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) { if auth == nil || cfg == nil { return @@ -72,9 +74,13 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey } if authKindKey == "apikey" { add(perKey) - } else if cfg.OAuthExcludedModels != nil { - providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) - add(cfg.OAuthExcludedModels[providerKey]) + } else { + // For OAuth: merge per-account excluded models with global provider-level exclusions + add(perKey) + if cfg.OAuthExcludedModels != nil { + providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + add(cfg.OAuthExcludedModels[providerKey]) + } } combined := make([]string, 0, len(seen)) for k := range seen { @@ -88,6 +94,10 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey if hash != "" { auth.Attributes["excluded_models_hash"] = hash } + // Store the combined excluded models list so that routing can read it at runtime + if len(combined) > 0 { + auth.Attributes["excluded_models"] = strings.Join(combined, ",") + } if authKind != "" { auth.Attributes["auth_kind"] = authKind } From 4cbcc835d1e7fc616a23a6d516e9cc68b1282d40 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:21:19 +0800 Subject: [PATCH 13/16] feat: read per-account excluded_models at routing time --- sdk/cliproxy/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 0ae05c08..b77de8c6 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -740,6 +740,26 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { provider = "openai-compatibility" } excluded := s.oauthExcludedModels(provider, authKind) + // Merge per-account excluded models from auth attributes (set by synthesizer) + if a.Attributes != nil { + if perAccount := strings.TrimSpace(a.Attributes["excluded_models"]); perAccount != "" { + parts := strings.Split(perAccount, ",") + seen := make(map[string]struct{}, len(excluded)+len(parts)) + for _, e := range excluded { + seen[strings.ToLower(strings.TrimSpace(e))] = struct{}{} + } + for _, p := range parts { + seen[strings.ToLower(strings.TrimSpace(p))] = struct{}{} + } + merged := make([]string, 0, len(seen)) + for k := range seen { + if k != "" { + merged = append(merged, k) + } + } + excluded = merged + } + } var models []*ModelInfo switch provider { case "gemini": From bf1634bda0fe3388a50e00ac227ad653639ec7e5 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:57:15 +0800 Subject: [PATCH 14/16] refactor: simplify per-account excluded_models merge in routing --- sdk/cliproxy/service.go | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index b77de8c6..536329b5 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -740,24 +740,11 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { provider = "openai-compatibility" } excluded := s.oauthExcludedModels(provider, authKind) - // Merge per-account excluded models from auth attributes (set by synthesizer) + // The synthesizer pre-merges per-account and global exclusions into the "excluded_models" attribute. + // If this attribute is present, it represents the complete list of exclusions and overrides the global config. if a.Attributes != nil { - if perAccount := strings.TrimSpace(a.Attributes["excluded_models"]); perAccount != "" { - parts := strings.Split(perAccount, ",") - seen := make(map[string]struct{}, len(excluded)+len(parts)) - for _, e := range excluded { - seen[strings.ToLower(strings.TrimSpace(e))] = struct{}{} - } - for _, p := range parts { - seen[strings.ToLower(strings.TrimSpace(p))] = struct{}{} - } - merged := make([]string, 0, len(seen)) - for k := range seen { - if k != "" { - merged = append(merged, k) - } - } - excluded = merged + if val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" { + excluded = strings.Split(val, ",") } } var models []*ModelInfo From dc279de443f60594c01efae29011ea59503f6aef Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:57:16 +0800 Subject: [PATCH 15/16] refactor: reduce code duplication in extractExcludedModelsFromMetadata --- internal/watcher/synthesizer/file.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 20b2faec..8f4ec6da 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -273,26 +273,25 @@ func extractExcludedModelsFromMetadata(metadata map[string]any) []string { if !ok || raw == nil { return nil } + var stringSlice []string switch v := raw.(type) { case []string: - result := make([]string, 0, len(v)) - for _, s := range v { - if trimmed := strings.TrimSpace(s); trimmed != "" { - result = append(result, trimmed) - } - } - return result + stringSlice = v case []interface{}: - result := make([]string, 0, len(v)) + stringSlice = make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { - if trimmed := strings.TrimSpace(s); trimmed != "" { - result = append(result, trimmed) - } + stringSlice = append(stringSlice, s) } } - return result default: return nil } + result := make([]string, 0, len(stringSlice)) + for _, s := range stringSlice { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + return result } From 4c133d3ea9dc77b740b5b454d7bc582a1045b37b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 11 Feb 2026 20:35:13 +0800 Subject: [PATCH 16/16] test(sdk/watcher): add tests for excluded models merging and priority parsing logic - Added unit tests for combining OAuth excluded models across global and attribute-specific scopes. - Implemented priority attribute parsing with support for different formats and trimming. --- internal/watcher/synthesizer/file.go | 5 +- internal/watcher/synthesizer/file_test.go | 118 +++++++++++++++++++ internal/watcher/synthesizer/helpers_test.go | 25 ++++ sdk/cliproxy/service_excluded_models_test.go | 65 ++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 sdk/cliproxy/service_excluded_models_test.go diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 8f4ec6da..4e053117 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -118,8 +118,9 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e case float64: a.Attributes["priority"] = strconv.Itoa(int(v)) case string: - if _, err := strconv.Atoi(v); err == nil { - a.Attributes["priority"] = v + priority := strings.TrimSpace(v) + if _, errAtoi := strconv.Atoi(priority); errAtoi == nil { + a.Attributes["priority"] = priority } } } diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index 93025fba..105d9207 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -297,6 +297,117 @@ func TestFileSynthesizer_Synthesize_PrefixValidation(t *testing.T) { } } +func TestFileSynthesizer_Synthesize_PriorityParsing(t *testing.T) { + tests := []struct { + name string + priority any + want string + hasValue bool + }{ + { + name: "string with spaces", + priority: " 10 ", + want: "10", + hasValue: true, + }, + { + name: "number", + priority: 8, + want: "8", + hasValue: true, + }, + { + name: "invalid string", + priority: "1x", + hasValue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "claude", + "priority": tt.priority, + } + data, _ := json.Marshal(authData) + errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644) + if errWriteFile != nil { + t.Fatalf("failed to write auth file: %v", errWriteFile) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("unexpected error: %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + value, ok := auths[0].Attributes["priority"] + if tt.hasValue { + if !ok { + t.Fatal("expected priority attribute to be set") + } + if value != tt.want { + t.Fatalf("expected priority %q, got %q", tt.want, value) + } + return + } + if ok { + t.Fatalf("expected priority attribute to be absent, got %q", value) + } + }) + } +} + +func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "claude", + "excluded_models": []string{"custom-model", "MODEL-B"}, + } + data, _ := json.Marshal(authData) + errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644) + if errWriteFile != nil { + t.Fatalf("failed to write auth file: %v", errWriteFile) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + OAuthExcludedModels: map[string][]string{ + "claude": {"shared", "model-b"}, + }, + }, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("unexpected error: %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + got := auths[0].Attributes["excluded_models"] + want := "custom-model,model-b,shared" + if got != want { + t.Fatalf("expected excluded_models %q, got %q", want, got) + } +} + func TestSynthesizeGeminiVirtualAuths_NilInputs(t *testing.T) { now := time.Now() @@ -533,6 +644,7 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) { "type": "gemini", "email": "multi@example.com", "project_id": "project-a, project-b, project-c", + "priority": " 10 ", } data, _ := json.Marshal(authData) err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644) @@ -565,6 +677,9 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) { if primary.Status != coreauth.StatusDisabled { t.Errorf("expected primary status disabled, got %s", primary.Status) } + if gotPriority := primary.Attributes["priority"]; gotPriority != "10" { + t.Errorf("expected primary priority 10, got %q", gotPriority) + } // Remaining auths should be virtuals for i := 1; i < 4; i++ { @@ -575,6 +690,9 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) { if v.Attributes["gemini_virtual_parent"] != primary.ID { t.Errorf("expected virtual %d parent to be %s, got %s", i, primary.ID, v.Attributes["gemini_virtual_parent"]) } + if gotPriority := v.Attributes["priority"]; gotPriority != "10" { + t.Errorf("expected virtual %d priority 10, got %q", i, gotPriority) + } } } diff --git a/internal/watcher/synthesizer/helpers_test.go b/internal/watcher/synthesizer/helpers_test.go index 229c75bc..46b9c8a0 100644 --- a/internal/watcher/synthesizer/helpers_test.go +++ b/internal/watcher/synthesizer/helpers_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) @@ -200,6 +201,30 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) { } } +func TestApplyAuthExcludedModelsMeta_OAuthMergeWritesCombinedModels(t *testing.T) { + auth := &coreauth.Auth{ + Provider: "claude", + Attributes: make(map[string]string), + } + cfg := &config.Config{ + OAuthExcludedModels: map[string][]string{ + "claude": {"global-a", "shared"}, + }, + } + + ApplyAuthExcludedModelsMeta(auth, cfg, []string{"per", "SHARED"}, "oauth") + + const wantCombined = "global-a,per,shared" + if gotCombined := auth.Attributes["excluded_models"]; gotCombined != wantCombined { + t.Fatalf("expected excluded_models=%q, got %q", wantCombined, gotCombined) + } + + expectedHash := diff.ComputeExcludedModelsHash([]string{"global-a", "per", "shared"}) + if gotHash := auth.Attributes["excluded_models_hash"]; gotHash != expectedHash { + t.Fatalf("expected excluded_models_hash=%q, got %q", expectedHash, gotHash) + } +} + func TestAddConfigHeadersToAttrs(t *testing.T) { tests := []struct { name string diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go new file mode 100644 index 00000000..198a5bed --- /dev/null +++ b/sdk/cliproxy/service_excluded_models_test.go @@ -0,0 +1,65 @@ +package cliproxy + +import ( + "strings" + "testing" + + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) { + service := &Service{ + cfg: &config.Config{ + OAuthExcludedModels: map[string][]string{ + "gemini-cli": {"gemini-2.5-pro"}, + }, + }, + } + auth := &coreauth.Auth{ + ID: "auth-gemini-cli", + Provider: "gemini-cli", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "auth_kind": "oauth", + "excluded_models": "gemini-2.5-flash", + }, + } + + registry := GlobalModelRegistry() + registry.UnregisterClient(auth.ID) + t.Cleanup(func() { + registry.UnregisterClient(auth.ID) + }) + + service.registerModelsForAuth(auth) + + models := registry.GetAvailableModelsByProvider("gemini-cli") + if len(models) == 0 { + t.Fatal("expected gemini-cli models to be registered") + } + + for _, model := range models { + if model == nil { + continue + } + modelID := strings.TrimSpace(model.ID) + if strings.EqualFold(modelID, "gemini-2.5-flash") { + t.Fatalf("expected model %q to be excluded by auth attribute", modelID) + } + } + + seenGlobalExcluded := false + for _, model := range models { + if model == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(model.ID), "gemini-2.5-pro") { + seenGlobalExcluded = true + break + } + } + if !seenGlobalExcluded { + t.Fatal("expected global excluded model to be present when attribute override is set") + } +}