mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 04:10:51 +08:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
575881cb59 | ||
|
|
f361b2716d | ||
|
|
58e09f8e5f | ||
|
|
a146c6c0aa | ||
|
|
4c133d3ea9 | ||
|
|
dc279de443 | ||
|
|
bf1634bda0 | ||
|
|
166d2d24d9 | ||
|
|
4cbcc835d1 | ||
|
|
b93026d83a | ||
|
|
5ed2133ff9 | ||
|
|
1510bfcb6f | ||
|
|
c6bd91b86b | ||
|
|
349ddcaa89 | ||
|
|
bb9fe52f1e | ||
|
|
afe4c1bfb7 | ||
|
|
865af9f19e | ||
|
|
2b97cb98b5 | ||
|
|
938a799263 | ||
|
|
0040d78496 | ||
|
|
896de027cc | ||
|
|
fc329ebf37 | ||
|
|
eaab1d6824 | ||
|
|
0cfe310df6 | ||
|
|
918b6955e4 | ||
|
|
5a3eb08739 | ||
|
|
0dff329162 | ||
|
|
49c1740b47 | ||
|
|
3fbee51e9f |
@@ -445,7 +445,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register built-in access providers before constructing services.
|
// Register built-in access providers before constructing services.
|
||||||
configaccess.Register()
|
configaccess.Register(&cfg.SDKConfig)
|
||||||
|
|
||||||
// Handle different command modes based on the provided flags.
|
// Handle different command modes based on the provided flags.
|
||||||
|
|
||||||
|
|||||||
@@ -224,22 +224,7 @@ nonstream-keepalive-interval: 0
|
|||||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi.
|
# 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.
|
# 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.
|
# You can repeat the same name with different aliases to expose multiple client model names.
|
||||||
oauth-model-alias:
|
# 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"
|
|
||||||
# gemini-cli:
|
# gemini-cli:
|
||||||
# - name: "gemini-2.5-pro" # original model name under this channel
|
# - name: "gemini-2.5-pro" # original model name under this channel
|
||||||
# alias: "g2.5p" # client-visible alias
|
# alias: "g2.5p" # client-visible alias
|
||||||
@@ -250,6 +235,9 @@ oauth-model-alias:
|
|||||||
# aistudio:
|
# aistudio:
|
||||||
# - name: "gemini-2.5-pro"
|
# - name: "gemini-2.5-pro"
|
||||||
# alias: "g2.5p"
|
# alias: "g2.5p"
|
||||||
|
# antigravity:
|
||||||
|
# - name: "gemini-3-pro-high"
|
||||||
|
# alias: "gemini-3-pro-preview"
|
||||||
# claude:
|
# claude:
|
||||||
# - name: "claude-sonnet-4-5-20250929"
|
# - name: "claude-sonnet-4-5-20250929"
|
||||||
# alias: "cs4.5"
|
# alias: "cs4.5"
|
||||||
|
|||||||
@@ -7,80 +7,71 @@ The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inb
|
|||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
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`.
|
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
|
## Manager Lifecycle
|
||||||
|
|
||||||
```go
|
```go
|
||||||
manager := sdkaccess.NewManager()
|
manager := sdkaccess.NewManager()
|
||||||
providers, err := sdkaccess.BuildProviders(cfg)
|
manager.SetProviders(sdkaccess.RegisteredProviders())
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
manager.SetProviders(providers)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
* `NewManager` constructs an empty manager.
|
* `NewManager` constructs an empty manager.
|
||||||
* `SetProviders` replaces the provider slice using a defensive copy.
|
* `SetProviders` replaces the provider slice using a defensive copy.
|
||||||
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
|
* `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
|
## Authenticating Requests
|
||||||
|
|
||||||
```go
|
```go
|
||||||
result, err := manager.Authenticate(ctx, req)
|
result, authErr := manager.Authenticate(ctx, req)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case authErr == nil:
|
||||||
// Authentication succeeded; result describes the provider and principal.
|
// 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.
|
// No recognizable credentials were supplied.
|
||||||
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
|
||||||
// Supplied credentials were present but rejected.
|
// Supplied credentials were present but rejected.
|
||||||
default:
|
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.
|
`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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
|
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
|
```yaml
|
||||||
auth:
|
api-keys:
|
||||||
providers:
|
- sk-test-123
|
||||||
- name: inline-api
|
- sk-prod-456
|
||||||
type: config-api-key
|
|
||||||
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, import it for its registration side effect:
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
@@ -89,19 +80,11 @@ import (
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
|
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before you call `RegisteredProviders()` (or before `cliproxy.NewBuilder().Build()`).
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
||||||
### Metadata and auditing
|
### 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
|
## Writing Custom Providers
|
||||||
|
|
||||||
@@ -110,13 +93,13 @@ type customProvider struct{}
|
|||||||
|
|
||||||
func (p *customProvider) Identifier() string { return "my-provider" }
|
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")
|
token := r.Header.Get("X-Custom")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, sdkaccess.ErrNoCredentials
|
return nil, sdkaccess.NewNotHandledError()
|
||||||
}
|
}
|
||||||
if token != "expected" {
|
if token != "expected" {
|
||||||
return nil, sdkaccess.ErrInvalidCredential
|
return nil, sdkaccess.NewInvalidCredentialError()
|
||||||
}
|
}
|
||||||
return &sdkaccess.Result{
|
return &sdkaccess.Result{
|
||||||
Provider: p.Identifier(),
|
Provider: p.Identifier(),
|
||||||
@@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
sdkaccess.RegisterProvider("custom", &customProvider{})
|
||||||
return &customProvider{}, nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Error Semantics
|
||||||
|
|
||||||
- `ErrNoCredentials`: no credentials were present or recognized by any provider.
|
- `NewNoCredentialsError()` (`AuthErrorCodeNoCredentials`): no credentials were present or recognized. (HTTP 401)
|
||||||
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
|
- `NewInvalidCredentialError()` (`AuthErrorCodeInvalidCredential`): credentials were present but rejected. (HTTP 401)
|
||||||
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
|
- `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
|
## 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
|
```go
|
||||||
coreCfg, _ := config.LoadConfig("config.yaml")
|
coreCfg, _ := config.LoadConfig("config.yaml")
|
||||||
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
accessManager := sdkaccess.NewManager()
|
||||||
manager := sdkaccess.NewManager()
|
|
||||||
manager.SetProviders(providers)
|
|
||||||
|
|
||||||
svc, _ := cliproxy.NewBuilder().
|
svc, _ := cliproxy.NewBuilder().
|
||||||
WithConfig(coreCfg).
|
WithConfig(coreCfg).
|
||||||
WithAccessManager(manager).
|
WithConfigPath("config.yaml").
|
||||||
|
WithRequestAccessManager(accessManager).
|
||||||
Build()
|
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
|
```go
|
||||||
providers, err := sdkaccess.BuildProviders(newCfg)
|
// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
|
||||||
if err != nil {
|
configaccess.Register(&newCfg.SDKConfig)
|
||||||
log.Errorf("reload auth providers failed: %v", err)
|
accessManager.SetProviders(sdkaccess.RegisteredProviders())
|
||||||
return
|
|
||||||
}
|
|
||||||
accessManager.SetProviders(providers)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -7,80 +7,71 @@
|
|||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
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` 添加依赖。
|
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
|
||||||
|
|
||||||
|
## Provider Registry
|
||||||
|
|
||||||
|
访问提供者是全局注册,然后以快照形式挂到 `Manager` 上:
|
||||||
|
|
||||||
|
- `RegisterProvider(type, provider)` 注册一个已经初始化好的 provider 实例。
|
||||||
|
- 每个 `type` 第一次出现时会记录其注册顺序。
|
||||||
|
- `RegisteredProviders()` 会按该顺序返回 provider 列表。
|
||||||
|
|
||||||
## 管理器生命周期
|
## 管理器生命周期
|
||||||
|
|
||||||
```go
|
```go
|
||||||
manager := sdkaccess.NewManager()
|
manager := sdkaccess.NewManager()
|
||||||
providers, err := sdkaccess.BuildProviders(cfg)
|
manager.SetProviders(sdkaccess.RegisteredProviders())
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
manager.SetProviders(providers)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- `NewManager` 创建空管理器。
|
- `NewManager` 创建空管理器。
|
||||||
- `SetProviders` 替换提供者切片并做防御性拷贝。
|
- `SetProviders` 替换提供者切片并做防御性拷贝。
|
||||||
- `Providers` 返回适合并发读取的快照。
|
- `Providers` 返回适合并发读取的快照。
|
||||||
- `BuildProviders` 将 `config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
|
|
||||||
|
如果管理器本身为 `nil` 或未配置任何 provider,调用会返回 `nil, nil`,可视为关闭访问控制。
|
||||||
|
|
||||||
## 认证请求
|
## 认证请求
|
||||||
|
|
||||||
```go
|
```go
|
||||||
result, err := manager.Authenticate(ctx, req)
|
result, authErr := manager.Authenticate(ctx, req)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case authErr == nil:
|
||||||
// Authentication succeeded; result carries provider and principal.
|
// Authentication succeeded; result carries provider and principal.
|
||||||
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
|
||||||
// No recognizable credentials were supplied.
|
// No recognizable credentials were supplied.
|
||||||
case errors.Is(err, sdkaccess.ErrInvalidCredential):
|
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
|
||||||
// Credentials were present but rejected.
|
// Credentials were present but rejected.
|
||||||
default:
|
default:
|
||||||
// Provider surfaced a transport-level failure.
|
// Provider surfaced a transport-level failure.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` 或 `ErrInvalidCredential`,会在遍历结束后汇总给调用方。
|
`Manager.Authenticate` 会按顺序遍历 provider:遇到成功立即返回,`AuthErrorCodeNotHandled` 会继续尝试下一个;`AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` 会在遍历结束后汇总给调用方。
|
||||||
|
|
||||||
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
|
|
||||||
|
|
||||||
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
|
`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
|
```yaml
|
||||||
auth:
|
api-keys:
|
||||||
providers:
|
- sk-test-123
|
||||||
- name: inline-api
|
- sk-prod-456
|
||||||
type: config-api-key
|
|
||||||
api-keys:
|
|
||||||
- sk-test-123
|
|
||||||
- sk-prod-456
|
|
||||||
```
|
```
|
||||||
|
|
||||||
条目映射到 `config.AccessProvider`:`name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
|
## 引入外部 Go 模块提供者
|
||||||
|
|
||||||
### 引入外部 SDK 提供者
|
若要消费其它 Go 模块输出的访问提供者,直接用空白标识符导入以触发其 `init` 注册即可:
|
||||||
|
|
||||||
若要消费其它 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
|
```go
|
||||||
import (
|
import (
|
||||||
@@ -89,19 +80,11 @@ import (
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`。
|
空白导入可确保 `init` 先执行,从而在你调用 `RegisteredProviders()`(或 `cliproxy.NewBuilder().Build()`)之前完成 `sdkaccess.RegisterProvider`。
|
||||||
|
|
||||||
## 内建提供者
|
|
||||||
|
|
||||||
当前 SDK 默认内置:
|
|
||||||
|
|
||||||
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer`、`X-Goog-Api-Key`、`X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`。
|
|
||||||
|
|
||||||
导入第三方包即可通过 `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) 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")
|
token := r.Header.Get("X-Custom")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, sdkaccess.ErrNoCredentials
|
return nil, sdkaccess.NewNotHandledError()
|
||||||
}
|
}
|
||||||
if token != "expected" {
|
if token != "expected" {
|
||||||
return nil, sdkaccess.ErrInvalidCredential
|
return nil, sdkaccess.NewInvalidCredentialError()
|
||||||
}
|
}
|
||||||
return &sdkaccess.Result{
|
return &sdkaccess.Result{
|
||||||
Provider: p.Identifier(),
|
Provider: p.Identifier(),
|
||||||
@@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
|
sdkaccess.RegisterProvider("custom", &customProvider{})
|
||||||
return &customProvider{}, nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。
|
自定义提供者需要实现 `Identifier()` 与 `Authenticate()`。在 `init` 中用已初始化实例调用 `RegisterProvider` 注册到全局 registry。
|
||||||
|
|
||||||
## 错误语义
|
## 错误语义
|
||||||
|
|
||||||
- `ErrNoCredentials`:任何提供者都未识别到凭证。
|
- `NewNoCredentialsError()`(`AuthErrorCodeNoCredentials`):未提供或未识别到凭证。(HTTP 401)
|
||||||
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
|
- `NewInvalidCredentialError()`(`AuthErrorCodeInvalidCredential`):凭证存在但校验失败。(HTTP 401)
|
||||||
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。
|
- `NewNotHandledError()`(`AuthErrorCodeNotHandled`):告诉管理器跳到下一个 provider。
|
||||||
|
- `NewInternalAuthError(message, cause)`(`AuthErrorCodeInternal`):网络/系统错误。(HTTP 500)
|
||||||
|
|
||||||
自定义错误(例如网络异常)会马上冒泡返回。
|
除可汇总的 `not_handled` / `no_credentials` / `invalid_credential` 外,其它错误会立即冒泡返回。
|
||||||
|
|
||||||
## 与 cliproxy 集成
|
## 与 cliproxy 集成
|
||||||
|
|
||||||
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
|
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果希望在宿主进程里复用同一个 `Manager` 实例,可传入自定义管理器:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
coreCfg, _ := config.LoadConfig("config.yaml")
|
coreCfg, _ := config.LoadConfig("config.yaml")
|
||||||
providers, _ := sdkaccess.BuildProviders(coreCfg)
|
accessManager := sdkaccess.NewManager()
|
||||||
manager := sdkaccess.NewManager()
|
|
||||||
manager.SetProviders(providers)
|
|
||||||
|
|
||||||
svc, _ := cliproxy.NewBuilder().
|
svc, _ := cliproxy.NewBuilder().
|
||||||
WithConfig(coreCfg).
|
WithConfig(coreCfg).
|
||||||
WithAccessManager(manager).
|
WithConfigPath("config.yaml").
|
||||||
|
WithRequestAccessManager(accessManager).
|
||||||
Build()
|
Build()
|
||||||
```
|
```
|
||||||
|
|
||||||
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。
|
请在调用 `Build()` 之前完成自定义 provider 的注册(通常通过空白导入触发 `init`),以确保它们被包含在全局 registry 的快照中。
|
||||||
|
|
||||||
### 动态热更新提供者
|
### 动态热更新提供者
|
||||||
|
|
||||||
当配置发生变化时,可以重新构建提供者并替换当前列表:
|
当配置发生变化时,刷新依赖配置的 provider,然后重置 manager 的 provider 链:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
providers, err := sdkaccess.BuildProviders(newCfg)
|
// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
|
||||||
if err != nil {
|
configaccess.Register(&newCfg.SDKConfig)
|
||||||
log.Errorf("reload auth providers failed: %v", err)
|
accessManager.SetProviders(sdkaccess.RegisteredProviders())
|
||||||
return
|
|
||||||
}
|
|
||||||
accessManager.SetProviders(providers)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
这一流程与 `cliproxy.Service.refreshAccessProviders` 和 `api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。
|
这一流程与 `internal/access.ApplyAccessProviders` 保持一致,避免为更新访问策略而重启进程。
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -22,6 +22,7 @@ require (
|
|||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/net v0.47.0
|
golang.org/x/net v0.47.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
|
golang.org/x/sync v0.18.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -69,7 +70,6 @@ require (
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
|
|||||||
@@ -4,19 +4,28 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
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.
|
// Register ensures the config-access provider is available to the access manager.
|
||||||
func Register() {
|
func Register(cfg *sdkconfig.SDKConfig) {
|
||||||
registerOnce.Do(func() {
|
if cfg == nil {
|
||||||
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
|
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 {
|
type provider struct {
|
||||||
@@ -24,34 +33,31 @@ type provider struct {
|
|||||||
keys map[string]struct{}
|
keys map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
|
func newProvider(name string, keys []string) *provider {
|
||||||
name := cfg.Name
|
providerName := strings.TrimSpace(name)
|
||||||
if name == "" {
|
if providerName == "" {
|
||||||
name = sdkconfig.DefaultAccessProviderName
|
providerName = sdkaccess.DefaultAccessProviderName
|
||||||
}
|
}
|
||||||
keys := make(map[string]struct{}, len(cfg.APIKeys))
|
keySet := make(map[string]struct{}, len(keys))
|
||||||
for _, key := range cfg.APIKeys {
|
for _, key := range keys {
|
||||||
if key == "" {
|
keySet[key] = struct{}{}
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys[key] = struct{}{}
|
|
||||||
}
|
}
|
||||||
return &provider{name: name, keys: keys}, nil
|
return &provider{name: providerName, keys: keySet}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *provider) Identifier() string {
|
func (p *provider) Identifier() string {
|
||||||
if p == nil || p.name == "" {
|
if p == nil || p.name == "" {
|
||||||
return sdkconfig.DefaultAccessProviderName
|
return sdkaccess.DefaultAccessProviderName
|
||||||
}
|
}
|
||||||
return p.name
|
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 {
|
if p == nil {
|
||||||
return nil, sdkaccess.ErrNotHandled
|
return nil, sdkaccess.NewNotHandledError()
|
||||||
}
|
}
|
||||||
if len(p.keys) == 0 {
|
if len(p.keys) == 0 {
|
||||||
return nil, sdkaccess.ErrNotHandled
|
return nil, sdkaccess.NewNotHandledError()
|
||||||
}
|
}
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
|
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")
|
queryAuthToken = r.URL.Query().Get("auth_token")
|
||||||
}
|
}
|
||||||
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
|
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
|
||||||
return nil, sdkaccess.ErrNoCredentials
|
return nil, sdkaccess.NewNoCredentialsError()
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := extractBearerToken(authHeader)
|
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 {
|
func extractBearerToken(header string) string {
|
||||||
@@ -110,3 +116,26 @@ func extractBearerToken(header string) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(parts[1])
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
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"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,26 +17,26 @@ import (
|
|||||||
// ordered provider slice along with the identifiers of providers that were added, updated, or
|
// ordered provider slice along with the identifiers of providers that were added, updated, or
|
||||||
// removed compared to the previous configuration.
|
// removed compared to the previous configuration.
|
||||||
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
|
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
|
||||||
|
_ = oldCfg
|
||||||
if newCfg == nil {
|
if newCfg == nil {
|
||||||
return nil, nil, nil, nil, nil
|
return nil, nil, nil, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result = sdkaccess.RegisteredProviders()
|
||||||
|
|
||||||
existingMap := make(map[string]sdkaccess.Provider, len(existing))
|
existingMap := make(map[string]sdkaccess.Provider, len(existing))
|
||||||
for _, provider := range existing {
|
for _, provider := range existing {
|
||||||
if provider == nil {
|
providerID := identifierFromProvider(provider)
|
||||||
|
if providerID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
existingMap[provider.Identifier()] = provider
|
existingMap[providerID] = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
oldCfgMap := accessProviderMap(oldCfg)
|
finalIDs := make(map[string]struct{}, len(result))
|
||||||
newEntries := collectProviderEntries(newCfg)
|
|
||||||
|
|
||||||
result = make([]sdkaccess.Provider, 0, len(newEntries))
|
|
||||||
finalIDs := make(map[string]struct{}, len(newEntries))
|
|
||||||
|
|
||||||
isInlineProvider := func(id string) bool {
|
isInlineProvider := func(id string) bool {
|
||||||
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
|
return strings.EqualFold(id, sdkaccess.DefaultAccessProviderName)
|
||||||
}
|
}
|
||||||
appendChange := func(list *[]string, id string) {
|
appendChange := func(list *[]string, id string) {
|
||||||
if isInlineProvider(id) {
|
if isInlineProvider(id) {
|
||||||
@@ -45,85 +45,28 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov
|
|||||||
*list = append(*list, id)
|
*list = append(*list, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, providerCfg := range newEntries {
|
for _, provider := range result {
|
||||||
key := providerIdentifier(providerCfg)
|
providerID := identifierFromProvider(provider)
|
||||||
if key == "" {
|
if providerID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
finalIDs[providerID] = struct{}{}
|
||||||
|
|
||||||
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
|
existingProvider, exists := existingMap[providerID]
|
||||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
if !exists {
|
||||||
isAliased := oldCfgProvider == providerCfg
|
appendChange(&added, providerID)
|
||||||
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
continue
|
||||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
|
||||||
result = append(result, existingProvider)
|
|
||||||
finalIDs[key] = struct{}{}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if !providerInstanceEqual(existingProvider, provider) {
|
||||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
appendChange(&updated, providerID)
|
||||||
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{}{}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removed = make([]string, 0, len(removedSet))
|
for providerID := range existingMap {
|
||||||
for id := range removedSet {
|
if _, exists := finalIDs[providerID]; exists {
|
||||||
removed = append(removed, id)
|
continue
|
||||||
|
}
|
||||||
|
appendChange(&removed, providerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(added)
|
sort.Strings(added)
|
||||||
@@ -142,6 +85,7 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing := manager.Providers()
|
existing := manager.Providers()
|
||||||
|
configaccess.Register(&newCfg.SDKConfig)
|
||||||
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
|
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("failed to reconcile request auth providers: %v", err)
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
|
func identifierFromProvider(provider sdkaccess.Provider) string {
|
||||||
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 {
|
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if name := strings.TrimSpace(provider.Name); name != "" {
|
return strings.TrimSpace(provider.Identifier())
|
||||||
return name
|
|
||||||
}
|
|
||||||
typ := strings.TrimSpace(provider.Type)
|
|
||||||
if typ == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
|
|
||||||
return sdkConfig.DefaultAccessProviderName
|
|
||||||
}
|
|
||||||
return typ
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
|
func providerInstanceEqual(a, b sdkaccess.Provider) bool {
|
||||||
if a == nil || b == nil {
|
if a == nil || b == nil {
|
||||||
return 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
|
return false
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
|
valueA := reflect.ValueOf(a)
|
||||||
return false
|
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 reflect.DeepEqual(a, b)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
||||||
h.putStringList(c, func(v []string) {
|
h.putStringList(c, func(v []string) {
|
||||||
h.cfg.APIKeys = append([]string(nil), v...)
|
h.cfg.APIKeys = append([]string(nil), v...)
|
||||||
h.cfg.Access.Providers = nil
|
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
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) {
|
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
|
// gemini-api-key: []GeminiKey
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (rw *ResponseRewriter) Flush() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// modelFieldPaths lists all JSON paths where model name may appear
|
// modelFieldPaths lists all JSON paths where model name may appear
|
||||||
var modelFieldPaths = []string{"model", "modelVersion", "response.modelVersion", "message.model"}
|
var modelFieldPaths = []string{"message.model", "model", "modelVersion", "response.model", "response.modelVersion"}
|
||||||
|
|
||||||
// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON
|
// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON
|
||||||
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility
|
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility
|
||||||
|
|||||||
110
internal/api/modules/amp/response_rewriter_test.go
Normal file
110
internal/api/modules/amp/response_rewriter_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package amp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_TopLevel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"id":"resp_1","model":"gpt-5.3-codex","output":[]}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
expected := `{"id":"resp_1","model":"gpt-5.2-codex","output":[]}`
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_ResponseModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex","status":"completed"}}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
expected := `{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.2-codex","status":"completed"}}`
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_ResponseCreated(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.3-codex","status":"in_progress"}}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
expected := `{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.2-codex","status":"in_progress"}}`
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_NoModelField(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"type":"response.output_item.added","item":{"id":"item_1","type":"message"}}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
if string(result) != string(input) {
|
||||||
|
t.Errorf("expected no modification, got %s", string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_EmptyOriginalModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: ""}
|
||||||
|
|
||||||
|
input := []byte(`{"model":"gpt-5.3-codex"}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
if string(result) != string(input) {
|
||||||
|
t.Errorf("expected no modification when originalModel is empty, got %s", string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteStreamChunk_SSEWithResponseModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
chunk := []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.3-codex\",\"status\":\"completed\"}}\n\n")
|
||||||
|
result := rw.rewriteStreamChunk(chunk)
|
||||||
|
|
||||||
|
expected := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.2-codex\",\"status\":\"completed\"}}\n\n"
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteStreamChunk_MultipleEvents(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
chunk := []byte("data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-5.3-codex\"}}\n\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_1\"}}\n\n")
|
||||||
|
result := rw.rewriteStreamChunk(chunk)
|
||||||
|
|
||||||
|
if string(result) == string(chunk) {
|
||||||
|
t.Error("expected response.model to be rewritten in SSE stream")
|
||||||
|
}
|
||||||
|
if !contains(result, []byte(`"model":"gpt-5.2-codex"`)) {
|
||||||
|
t.Errorf("expected rewritten model in output, got %s", string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteStreamChunk_MessageModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "claude-opus-4.5"}
|
||||||
|
|
||||||
|
chunk := []byte("data: {\"message\":{\"model\":\"claude-sonnet-4\",\"role\":\"assistant\"}}\n\n")
|
||||||
|
result := rw.rewriteStreamChunk(chunk)
|
||||||
|
|
||||||
|
expected := "data: {\"message\":{\"model\":\"claude-opus-4.5\",\"role\":\"assistant\"}}\n\n"
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(data, substr []byte) bool {
|
||||||
|
for i := 0; i <= len(data)-len(substr); i++ {
|
||||||
|
if string(data[i:i+len(substr)]) == string(substr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -655,14 +655,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
|||||||
|
|
||||||
if _, err := os.Stat(filePath); err != nil {
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)
|
// Synchronously ensure management.html is available with a detached context.
|
||||||
c.AbortWithStatus(http.StatusNotFound)
|
// Control panel bootstrap should not be canceled by client disconnects.
|
||||||
|
if !managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) {
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.WithError(err).Error("failed to stat management control panel asset")
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithError(err).Error("failed to stat management control panel asset")
|
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.File(filePath)
|
c.File(filePath)
|
||||||
@@ -1030,14 +1033,10 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
statusCode := err.HTTPStatusCode()
|
||||||
case errors.Is(err, sdkaccess.ErrNoCredentials):
|
if statusCode >= http.StatusInternalServerError {
|
||||||
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:
|
|
||||||
log.Errorf("authentication middleware error: %v", err)
|
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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,9 +589,6 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
cfg.ErrorLogsMaxFiles = 10
|
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.
|
// Sanitize Gemini API key configuration and migrate legacy entries.
|
||||||
cfg.SanitizeGeminiKeys()
|
cfg.SanitizeGeminiKeys()
|
||||||
|
|
||||||
@@ -825,18 +822,6 @@ func normalizeModelPrefix(prefix string) string {
|
|||||||
return trimmed
|
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.
|
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
|
||||||
func looksLikeBcrypt(s string) bool {
|
func looksLikeBcrypt(s string) bool {
|
||||||
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
|
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
|
// 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.
|
// 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 {
|
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||||
persistCfg := sanitizeConfigForPersist(cfg)
|
persistCfg := cfg
|
||||||
// Load original YAML as a node tree to preserve comments and ordering.
|
// Load original YAML as a node tree to preserve comments and ordering.
|
||||||
data, err := os.ReadFile(configFile)
|
data, err := os.ReadFile(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -992,16 +977,6 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
|||||||
return err
|
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"]
|
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
|
||||||
// while preserving comments and positions.
|
// while preserving comments and positions.
|
||||||
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ type SDKConfig struct {
|
|||||||
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||||
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
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 configures server-side streaming behavior (keep-alives and safe bootstrap retries).
|
||||||
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
|
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
|
||||||
|
|
||||||
@@ -42,65 +39,3 @@ type StreamingConfig struct {
|
|||||||
// <= 0 disables bootstrap retries. Default is 0.
|
// <= 0 disables bootstrap retries. Default is 0.
|
||||||
BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"`
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -41,6 +42,7 @@ var (
|
|||||||
currentConfigPtr atomic.Pointer[config.Config]
|
currentConfigPtr atomic.Pointer[config.Config]
|
||||||
schedulerOnce sync.Once
|
schedulerOnce sync.Once
|
||||||
schedulerConfigPath atomic.Value
|
schedulerConfigPath atomic.Value
|
||||||
|
sfGroup singleflight.Group
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
|
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
|
||||||
@@ -171,8 +173,8 @@ func FilePath(configFilePath string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
||||||
// The function is designed to run in a background goroutine and will never panic.
|
// It coalesces concurrent sync attempts and returns whether the asset exists after the sync attempt.
|
||||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) {
|
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -180,91 +182,97 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
|
|||||||
staticDir = strings.TrimSpace(staticDir)
|
staticDir = strings.TrimSpace(staticDir)
|
||||||
if staticDir == "" {
|
if staticDir == "" {
|
||||||
log.Debug("management asset sync skipped: empty static directory")
|
log.Debug("management asset sync skipped: empty static directory")
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUpdateCheckMu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
|
||||||
if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {
|
|
||||||
lastUpdateCheckMu.Unlock()
|
|
||||||
log.Debugf(
|
|
||||||
"management asset sync skipped by throttle: last attempt %v ago (interval %v)",
|
|
||||||
timeSinceLastAttempt.Round(time.Second),
|
|
||||||
managementSyncMinInterval,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
lastUpdateCheckTime = now
|
|
||||||
lastUpdateCheckMu.Unlock()
|
|
||||||
|
|
||||||
localPath := filepath.Join(staticDir, managementAssetName)
|
localPath := filepath.Join(staticDir, managementAssetName)
|
||||||
localFileMissing := false
|
|
||||||
if _, errStat := os.Stat(localPath); errStat != nil {
|
_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {
|
||||||
if errors.Is(errStat, os.ErrNotExist) {
|
lastUpdateCheckMu.Lock()
|
||||||
localFileMissing = true
|
now := time.Now()
|
||||||
} else {
|
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
||||||
log.WithError(errStat).Debug("failed to stat local management asset")
|
if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {
|
||||||
|
lastUpdateCheckMu.Unlock()
|
||||||
|
log.Debugf(
|
||||||
|
"management asset sync skipped by throttle: last attempt %v ago (interval %v)",
|
||||||
|
timeSinceLastAttempt.Round(time.Second),
|
||||||
|
managementSyncMinInterval,
|
||||||
|
)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
lastUpdateCheckTime = now
|
||||||
|
lastUpdateCheckMu.Unlock()
|
||||||
|
|
||||||
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
|
localFileMissing := false
|
||||||
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
if _, errStat := os.Stat(localPath); errStat != nil {
|
||||||
return
|
if errors.Is(errStat, os.ErrNotExist) {
|
||||||
}
|
localFileMissing = true
|
||||||
|
} else {
|
||||||
releaseURL := resolveReleaseURL(panelRepository)
|
log.WithError(errStat).Debug("failed to stat local management asset")
|
||||||
client := newHTTPClient(proxyURL)
|
|
||||||
|
|
||||||
localHash, err := fileSHA256(localPath)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
log.WithError(err).Debug("failed to read local management asset hash")
|
|
||||||
}
|
|
||||||
localHash = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL)
|
|
||||||
if err != nil {
|
|
||||||
if localFileMissing {
|
|
||||||
log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page")
|
|
||||||
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.WithError(err).Warn("failed to fetch latest management release information")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
|
||||||
log.Debug("management asset is already up to date")
|
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
releaseURL := resolveReleaseURL(panelRepository)
|
||||||
if err != nil {
|
client := newHTTPClient(proxyURL)
|
||||||
if localFileMissing {
|
|
||||||
log.WithError(err).Warn("failed to download management asset, trying fallback page")
|
localHash, err := fileSHA256(localPath)
|
||||||
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
if err != nil {
|
||||||
return
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.WithError(err).Debug("failed to read local management asset hash")
|
||||||
}
|
}
|
||||||
return
|
localHash = ""
|
||||||
}
|
}
|
||||||
log.WithError(err).Warn("failed to download management asset")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL)
|
||||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
if err != nil {
|
||||||
}
|
if localFileMissing {
|
||||||
|
log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page")
|
||||||
|
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.WithError(err).Warn("failed to fetch latest management release information")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err = atomicWriteFile(localPath, data); err != nil {
|
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||||
log.WithError(err).Warn("failed to update management asset on disk")
|
log.Debug("management asset is already up to date")
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
||||||
|
if err != nil {
|
||||||
|
if localFileMissing {
|
||||||
|
log.WithError(err).Warn("failed to download management asset, trying fallback page")
|
||||||
|
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
log.WithError(err).Warn("failed to download management asset")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
||||||
|
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = atomicWriteFile(localPath, data); err != nil {
|
||||||
|
log.WithError(err).Warn("failed to update management asset on disk")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := os.Stat(localPath)
|
||||||
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {
|
func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {
|
||||||
|
|||||||
@@ -814,6 +814,7 @@ func GetIFlowModels() []*ModelInfo {
|
|||||||
{ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905", Created: 1757030400},
|
{ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905", Created: 1757030400},
|
||||||
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400, Thinking: iFlowThinkingSupport},
|
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "glm-4.7", DisplayName: "GLM-4.7", Description: "Zhipu GLM 4.7 general model", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
{ID: "glm-4.7", DisplayName: "GLM-4.7", Description: "Zhipu GLM 4.7 general model", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
||||||
|
{ID: "glm-5", DisplayName: "GLM-5", Description: "Zhipu GLM 5 general model", Created: 1770768000, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
|
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
|
||||||
{ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 thinking model", Created: 1762387200},
|
{ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 thinking model", Created: 1762387200},
|
||||||
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000},
|
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000},
|
||||||
@@ -828,6 +829,7 @@ func GetIFlowModels() []*ModelInfo {
|
|||||||
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
|
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
|
||||||
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
|
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
{ID: "minimax-m2.1", DisplayName: "MiniMax-M2.1", Description: "MiniMax M2.1", Created: 1766448000, Thinking: iFlowThinkingSupport},
|
||||||
|
{ID: "minimax-m2.5", DisplayName: "MiniMax-M2.5", Description: "MiniMax M2.5", Created: 1770825600, Thinking: iFlowThinkingSupport},
|
||||||
{ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},
|
{ID: "iflow-rome-30ba3b", DisplayName: "iFlow-ROME", Description: "iFlow Rome 30BA3B model", Created: 1736899200},
|
||||||
{ID: "kimi-k2.5", DisplayName: "Kimi-K2.5", Description: "Moonshot Kimi K2.5", Created: 1769443200, Thinking: iFlowThinkingSupport},
|
{ID: "kimi-k2.5", DisplayName: "Kimi-K2.5", Description: "Moonshot Kimi K2.5", Created: 1769443200, Thinking: iFlowThinkingSupport},
|
||||||
}
|
}
|
||||||
@@ -866,7 +868,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"}}},
|
"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-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-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},
|
"claude-sonnet-4-5": {MaxCompletionTokens: 64000},
|
||||||
"gpt-oss-120b-medium": {},
|
"gpt-oss-120b-medium": {},
|
||||||
"tab_flash_lite_preview": {},
|
"tab_flash_lite_preview": {},
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAntigravityBuildRequest_SanitizesGeminiToolSchema(t *testing.T) {
|
||||||
|
body := buildRequestBodyFromPayload(t, "gemini-2.5-pro")
|
||||||
|
|
||||||
|
decl := extractFirstFunctionDeclaration(t, body)
|
||||||
|
if _, ok := decl["parametersJsonSchema"]; ok {
|
||||||
|
t.Fatalf("parametersJsonSchema should be renamed to parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
params, ok := decl["parameters"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("parameters missing or invalid type")
|
||||||
|
}
|
||||||
|
assertSchemaSanitizedAndPropertyPreserved(t, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntigravityBuildRequest_SanitizesAntigravityToolSchema(t *testing.T) {
|
||||||
|
body := buildRequestBodyFromPayload(t, "claude-opus-4-6")
|
||||||
|
|
||||||
|
decl := extractFirstFunctionDeclaration(t, body)
|
||||||
|
params, ok := decl["parameters"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("parameters missing or invalid type")
|
||||||
|
}
|
||||||
|
assertSchemaSanitizedAndPropertyPreserved(t, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRequestBodyFromPayload(t *testing.T, modelName string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
executor := &AntigravityExecutor{}
|
||||||
|
auth := &cliproxyauth.Auth{}
|
||||||
|
payload := []byte(`{
|
||||||
|
"request": {
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"function_declarations": [
|
||||||
|
{
|
||||||
|
"name": "tool_1",
|
||||||
|
"parametersJsonSchema": {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "root-schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"$id": {"type": "string"},
|
||||||
|
"arg": {
|
||||||
|
"type": "object",
|
||||||
|
"prefill": "hello",
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["a", "b"],
|
||||||
|
"enumTitles": ["A", "B"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patternProperties": {
|
||||||
|
"^x-": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
req, err := executor.buildRequest(context.Background(), auth, "token", modelName, payload, false, "", "https://example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRequest error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read request body error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal request body error: %v, body=%s", err, string(raw))
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFirstFunctionDeclaration(t *testing.T, body map[string]any) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
request, ok := body["request"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("request missing or invalid type")
|
||||||
|
}
|
||||||
|
tools, ok := request["tools"].([]any)
|
||||||
|
if !ok || len(tools) == 0 {
|
||||||
|
t.Fatalf("tools missing or empty")
|
||||||
|
}
|
||||||
|
tool, ok := tools[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("first tool invalid type")
|
||||||
|
}
|
||||||
|
decls, ok := tool["function_declarations"].([]any)
|
||||||
|
if !ok || len(decls) == 0 {
|
||||||
|
t.Fatalf("function_declarations missing or empty")
|
||||||
|
}
|
||||||
|
decl, ok := decls[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("first function declaration invalid type")
|
||||||
|
}
|
||||||
|
return decl
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSchemaSanitizedAndPropertyPreserved(t *testing.T, params map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if _, ok := params["$id"]; ok {
|
||||||
|
t.Fatalf("root $id should be removed from schema")
|
||||||
|
}
|
||||||
|
if _, ok := params["patternProperties"]; ok {
|
||||||
|
t.Fatalf("patternProperties should be removed from schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
props, ok := params["properties"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("properties missing or invalid type")
|
||||||
|
}
|
||||||
|
if _, ok := props["$id"]; !ok {
|
||||||
|
t.Fatalf("property named $id should be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
arg, ok := props["arg"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("arg property missing or invalid type")
|
||||||
|
}
|
||||||
|
if _, ok := arg["prefill"]; ok {
|
||||||
|
t.Fatalf("prefill should be removed from nested schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
argProps, ok := arg["properties"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("arg.properties missing or invalid type")
|
||||||
|
}
|
||||||
|
mode, ok := argProps["mode"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("mode property missing or invalid type")
|
||||||
|
}
|
||||||
|
if _, ok := mode["enumTitles"]; ok {
|
||||||
|
t.Fatalf("enumTitles should be removed from nested schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||||
@@ -453,6 +457,20 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) {
|
|||||||
r.Header.Set("Content-Type", "application/json")
|
r.Header.Set("Content-Type", "application/json")
|
||||||
r.Header.Set("Authorization", "Bearer "+apiKey)
|
r.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
r.Header.Set("User-Agent", iflowUserAgent)
|
r.Header.Set("User-Agent", iflowUserAgent)
|
||||||
|
|
||||||
|
// Generate session-id
|
||||||
|
sessionID := "session-" + generateUUID()
|
||||||
|
r.Header.Set("session-id", sessionID)
|
||||||
|
|
||||||
|
// Generate timestamp and signature
|
||||||
|
timestamp := time.Now().UnixMilli()
|
||||||
|
r.Header.Set("x-iflow-timestamp", fmt.Sprintf("%d", timestamp))
|
||||||
|
|
||||||
|
signature := createIFlowSignature(iflowUserAgent, sessionID, timestamp, apiKey)
|
||||||
|
if signature != "" {
|
||||||
|
r.Header.Set("x-iflow-signature", signature)
|
||||||
|
}
|
||||||
|
|
||||||
if stream {
|
if stream {
|
||||||
r.Header.Set("Accept", "text/event-stream")
|
r.Header.Set("Accept", "text/event-stream")
|
||||||
} else {
|
} else {
|
||||||
@@ -460,6 +478,23 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createIFlowSignature generates HMAC-SHA256 signature for iFlow API requests.
|
||||||
|
// The signature payload format is: userAgent:sessionId:timestamp
|
||||||
|
func createIFlowSignature(userAgent, sessionID string, timestamp int64, apiKey string) string {
|
||||||
|
if apiKey == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload := fmt.Sprintf("%s:%s:%d", userAgent, sessionID, timestamp)
|
||||||
|
h := hmac.New(sha256.New, []byte(apiKey))
|
||||||
|
h.Write([]byte(payload))
|
||||||
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateUUID generates a random UUID v4 string.
|
||||||
|
func generateUUID() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|||||||
@@ -344,7 +344,8 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
// Inject interleaved thinking hint when both tools and thinking are active
|
// Inject interleaved thinking hint when both tools and thinking are active
|
||||||
hasTools := toolDeclCount > 0
|
hasTools := toolDeclCount > 0
|
||||||
thinkingResult := gjson.GetBytes(rawJSON, "thinking")
|
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)
|
isClaudeThinking := util.IsClaudeThinkingModel(modelName)
|
||||||
|
|
||||||
if hasTools && hasThinking && isClaudeThinking {
|
if hasTools && hasThinking && isClaudeThinking {
|
||||||
@@ -377,12 +378,18 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
|
|
||||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() {
|
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 {
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
budget := int(b.Int())
|
budget := int(b.Int())
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
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 {
|
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
|
||||||
|
|||||||
@@ -222,6 +222,10 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
reasoningEffort = effort
|
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":
|
case "disabled":
|
||||||
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||||
reasoningEffort = effort
|
reasoningEffort = effort
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,
|
|||||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")
|
||||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier")
|
rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier")
|
||||||
|
|
||||||
|
// 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.
|
// Convert role "system" to "developer" in input array to comply with Codex API requirements.
|
||||||
rawJSON = convertSystemRoleToDeveloper(rawJSON)
|
rawJSON = convertSystemRoleToDeveloper(rawJSON)
|
||||||
|
|
||||||
|
|||||||
@@ -263,3 +263,20 @@ func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) {
|
|||||||
t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String())
|
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.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -173,12 +173,18 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
|||||||
|
|
||||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
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 {
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
budget := int(b.Int())
|
budget := int(b.Int())
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
out, _ = sjson.Set(out, "request.generationConfig.thinkingConfig.includeThoughts", true)
|
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 {
|
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
|
||||||
|
|||||||
@@ -154,12 +154,18 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
|||||||
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
|
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
|
||||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
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 {
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
budget := int(b.Int())
|
budget := int(b.Int())
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", true)
|
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 {
|
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
|
||||||
|
|||||||
@@ -117,19 +117,29 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
|||||||
switch itemType {
|
switch itemType {
|
||||||
case "message":
|
case "message":
|
||||||
if strings.EqualFold(itemRole, "system") {
|
if strings.EqualFold(itemRole, "system") {
|
||||||
if contentArray := item.Get("content"); contentArray.Exists() && contentArray.IsArray() {
|
if contentArray := item.Get("content"); contentArray.Exists() {
|
||||||
var builder strings.Builder
|
systemInstr := ""
|
||||||
contentArray.ForEach(func(_, contentItem gjson.Result) bool {
|
if systemInstructionResult := gjson.Get(out, "system_instruction"); systemInstructionResult.Exists() {
|
||||||
text := contentItem.Get("text").String()
|
systemInstr = systemInstructionResult.Raw
|
||||||
if builder.Len() > 0 && text != "" {
|
} else {
|
||||||
builder.WriteByte('\n')
|
systemInstr = `{"parts":[]}`
|
||||||
}
|
}
|
||||||
builder.WriteString(text)
|
|
||||||
return true
|
if contentArray.IsArray() {
|
||||||
})
|
contentArray.ForEach(func(_, contentItem gjson.Result) bool {
|
||||||
if !gjson.Get(out, "system_instruction").Exists() {
|
part := `{"text":""}`
|
||||||
systemInstr := `{"parts":[{"text":""}]}`
|
text := contentItem.Get("text").String()
|
||||||
systemInstr, _ = sjson.Set(systemInstr, "parts.0.text", builder.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)
|
out, _ = sjson.SetRaw(out, "system_instruction", systemInstr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,8 +246,22 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
|||||||
})
|
})
|
||||||
|
|
||||||
flush()
|
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":
|
case "function_call":
|
||||||
// Handle function calls - convert to model message with functionCall
|
// Handle function calls - convert to model message with functionCall
|
||||||
name := item.Get("name").String()
|
name := item.Get("name").String()
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
|||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
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":
|
case "disabled":
|
||||||
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
|
||||||
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
out, _ = sjson.Set(out, "reasoning_effort", effort)
|
||||||
|
|||||||
@@ -428,8 +428,9 @@ func flattenTypeArrays(jsonStr string) string {
|
|||||||
|
|
||||||
func removeUnsupportedKeywords(jsonStr string) string {
|
func removeUnsupportedKeywords(jsonStr string) string {
|
||||||
keywords := append(unsupportedConstraints,
|
keywords := append(unsupportedConstraints,
|
||||||
"$schema", "$defs", "definitions", "const", "$ref", "additionalProperties",
|
"$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties",
|
||||||
"propertyNames", // Gemini doesn't support property name validation
|
"propertyNames", "patternProperties", // Gemini doesn't support these schema keywords
|
||||||
|
"enumTitles", "prefill", // Claude/OpenCode schema metadata fields unsupported by Gemini
|
||||||
)
|
)
|
||||||
|
|
||||||
deletePaths := make([]string, 0)
|
deletePaths := make([]string, 0)
|
||||||
|
|||||||
@@ -870,6 +870,57 @@ func TestCleanJSONSchemaForAntigravity_BooleanEnumToString(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCleanJSONSchemaForGemini_RemovesGeminiUnsupportedMetadataFields(t *testing.T) {
|
||||||
|
input := `{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "root-schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"prefill": "hello",
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["a", "b"],
|
||||||
|
"enumTitles": ["A", "B"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patternProperties": {
|
||||||
|
"^x-": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "property name should not be removed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
expected := `{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"payload": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["a", "b"],
|
||||||
|
"description": "Allowed: a, b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "property name should not be removed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := CleanJSONSchemaForGemini(input)
|
||||||
|
compareJSON(t, expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoveExtensionFields(t *testing.T) {
|
func TestRemoveExtensionFields(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -92,6 +93,9 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e
|
|||||||
status = coreauth.StatusDisabled
|
status = coreauth.StatusDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read per-account excluded models from the OAuth JSON file
|
||||||
|
perAccountExcluded := extractExcludedModelsFromMetadata(metadata)
|
||||||
|
|
||||||
a := &coreauth.Auth{
|
a := &coreauth.Auth{
|
||||||
ID: id,
|
ID: id,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
@@ -108,11 +112,23 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: 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:
|
||||||
|
priority := strings.TrimSpace(v)
|
||||||
|
if _, errAtoi := strconv.Atoi(priority); errAtoi == nil {
|
||||||
|
a.Attributes["priority"] = priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth")
|
||||||
if provider == "gemini-cli" {
|
if provider == "gemini-cli" {
|
||||||
if virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 {
|
if virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 {
|
||||||
for _, v := range virtuals {
|
for _, v := range virtuals {
|
||||||
ApplyAuthExcludedModelsMeta(v, cfg, nil, "oauth")
|
ApplyAuthExcludedModelsMeta(v, cfg, perAccountExcluded, "oauth")
|
||||||
}
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
out = append(out, virtuals...)
|
out = append(out, virtuals...)
|
||||||
@@ -167,6 +183,10 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
|
|||||||
if authPath != "" {
|
if authPath != "" {
|
||||||
attrs["path"] = 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{
|
metadataCopy := map[string]any{
|
||||||
"email": email,
|
"email": email,
|
||||||
"project_id": projectID,
|
"project_id": projectID,
|
||||||
@@ -239,3 +259,40 @@ func buildGeminiVirtualID(baseID, projectID string) string {
|
|||||||
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
|
replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_")
|
||||||
return fmt.Sprintf("%s::%s", baseID, replacer.Replace(project))
|
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
|
||||||
|
}
|
||||||
|
var stringSlice []string
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
stringSlice = v
|
||||||
|
case []interface{}:
|
||||||
|
stringSlice = make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
stringSlice = append(stringSlice, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestSynthesizeGeminiVirtualAuths_NilInputs(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
@@ -533,6 +644,7 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) {
|
|||||||
"type": "gemini",
|
"type": "gemini",
|
||||||
"email": "multi@example.com",
|
"email": "multi@example.com",
|
||||||
"project_id": "project-a, project-b, project-c",
|
"project_id": "project-a, project-b, project-c",
|
||||||
|
"priority": " 10 ",
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(authData)
|
data, _ := json.Marshal(authData)
|
||||||
err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644)
|
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 {
|
if primary.Status != coreauth.StatusDisabled {
|
||||||
t.Errorf("expected primary status disabled, got %s", primary.Status)
|
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
|
// Remaining auths should be virtuals
|
||||||
for i := 1; i < 4; i++ {
|
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 {
|
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"])
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ func (g *StableIDGenerator) Next(kind string, parts ...string) (string, string)
|
|||||||
|
|
||||||
// ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry.
|
// ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry.
|
||||||
// It computes a hash of excluded models and sets the auth_kind attribute.
|
// 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) {
|
func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) {
|
||||||
if auth == nil || cfg == nil {
|
if auth == nil || cfg == nil {
|
||||||
return
|
return
|
||||||
@@ -72,9 +74,13 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey
|
|||||||
}
|
}
|
||||||
if authKindKey == "apikey" {
|
if authKindKey == "apikey" {
|
||||||
add(perKey)
|
add(perKey)
|
||||||
} else if cfg.OAuthExcludedModels != nil {
|
} else {
|
||||||
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
|
// For OAuth: merge per-account excluded models with global provider-level exclusions
|
||||||
add(cfg.OAuthExcludedModels[providerKey])
|
add(perKey)
|
||||||
|
if cfg.OAuthExcludedModels != nil {
|
||||||
|
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||||
|
add(cfg.OAuthExcludedModels[providerKey])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
combined := make([]string, 0, len(seen))
|
combined := make([]string, 0, len(seen))
|
||||||
for k := range seen {
|
for k := range seen {
|
||||||
@@ -88,6 +94,10 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey
|
|||||||
if hash != "" {
|
if hash != "" {
|
||||||
auth.Attributes["excluded_models_hash"] = 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 != "" {
|
if authKind != "" {
|
||||||
auth.Attributes["auth_kind"] = authKind
|
auth.Attributes["auth_kind"] = authKind
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"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"
|
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) {
|
func TestAddConfigHeadersToAttrs(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,12 +1,90 @@
|
|||||||
package access
|
package access
|
||||||
|
|
||||||
import "errors"
|
import (
|
||||||
|
"fmt"
|
||||||
var (
|
"net/http"
|
||||||
// ErrNoCredentials indicates no recognizable credentials were supplied.
|
"strings"
|
||||||
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")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package access
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -43,7 +42,7 @@ func (m *Manager) Providers() []Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate evaluates providers until one succeeds.
|
// 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 {
|
if m == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -61,29 +60,29 @@ func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, e
|
|||||||
if provider == nil {
|
if provider == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res, err := provider.Authenticate(ctx, r)
|
res, authErr := provider.Authenticate(ctx, r)
|
||||||
if err == nil {
|
if authErr == nil {
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrNotHandled) {
|
if IsAuthErrorCode(authErr, AuthErrorCodeNotHandled) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrNoCredentials) {
|
if IsAuthErrorCode(authErr, AuthErrorCodeNoCredentials) {
|
||||||
missing = true
|
missing = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if errors.Is(err, ErrInvalidCredential) {
|
if IsAuthErrorCode(authErr, AuthErrorCodeInvalidCredential) {
|
||||||
invalid = true
|
invalid = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, authErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if invalid {
|
if invalid {
|
||||||
return nil, ErrInvalidCredential
|
return nil, NewInvalidCredentialError()
|
||||||
}
|
}
|
||||||
if missing {
|
if missing {
|
||||||
return nil, ErrNoCredentials
|
return nil, NewNoCredentialsError()
|
||||||
}
|
}
|
||||||
return nil, ErrNoCredentials
|
return nil, NewNoCredentialsError()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,15 @@ package access
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Provider validates credentials for incoming requests.
|
// Provider validates credentials for incoming requests.
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
Identifier() string
|
Identifier() string
|
||||||
Authenticate(ctx context.Context, r *http.Request) (*Result, error)
|
Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result conveys authentication outcome.
|
// Result conveys authentication outcome.
|
||||||
@@ -22,66 +20,64 @@ type Result struct {
|
|||||||
Metadata map[string]string
|
Metadata map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderFactory builds a provider from configuration data.
|
|
||||||
type ProviderFactory func(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
registryMu sync.RWMutex
|
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.
|
// RegisterProvider registers a pre-built provider instance for a given type identifier.
|
||||||
func RegisterProvider(typ string, factory ProviderFactory) {
|
func RegisterProvider(typ string, provider Provider) {
|
||||||
if typ == "" || factory == nil {
|
normalizedType := strings.TrimSpace(typ)
|
||||||
|
if normalizedType == "" || provider == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
registryMu.Lock()
|
registryMu.Lock()
|
||||||
registry[typ] = factory
|
if _, exists := registry[normalizedType]; !exists {
|
||||||
|
order = append(order, normalizedType)
|
||||||
|
}
|
||||||
|
registry[normalizedType] = provider
|
||||||
registryMu.Unlock()
|
registryMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildProvider(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error) {
|
// UnregisterProvider removes a provider by type identifier.
|
||||||
if cfg == nil {
|
func UnregisterProvider(typ string) {
|
||||||
return nil, fmt.Errorf("access: nil provider config")
|
normalizedType := strings.TrimSpace(typ)
|
||||||
|
if normalizedType == "" {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
registryMu.RLock()
|
registryMu.Lock()
|
||||||
factory, ok := registry[cfg.Type]
|
if _, exists := registry[normalizedType]; !exists {
|
||||||
registryMu.RUnlock()
|
registryMu.Unlock()
|
||||||
if !ok {
|
return
|
||||||
return nil, fmt.Errorf("access: provider type %q is not registered", cfg.Type)
|
|
||||||
}
|
}
|
||||||
provider, err := factory(cfg, root)
|
delete(registry, normalizedType)
|
||||||
if err != nil {
|
for index := range order {
|
||||||
return nil, fmt.Errorf("access: failed to build provider %q: %w", cfg.Name, err)
|
if order[index] != normalizedType {
|
||||||
}
|
|
||||||
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 == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
provider, err := BuildProvider(providerCfg, root)
|
order = append(order[:index], order[index+1:]...)
|
||||||
if err != nil {
|
break
|
||||||
return nil, err
|
}
|
||||||
|
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)
|
providers = append(providers, provider)
|
||||||
}
|
}
|
||||||
if len(providers) == 0 {
|
registryMu.RUnlock()
|
||||||
if inline := config.MakeInlineAPIKeyProvider(root.APIKeys); inline != nil {
|
return providers
|
||||||
provider, err := BuildProvider(inline, root)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
providers = append(providers, provider)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return providers, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
sdk/access/types.go
Normal file
47
sdk/access/types.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||||
@@ -186,11 +187,8 @@ func (b *Builder) Build() (*Service, error) {
|
|||||||
accessManager = sdkaccess.NewManager()
|
accessManager = sdkaccess.NewManager()
|
||||||
}
|
}
|
||||||
|
|
||||||
providers, err := sdkaccess.BuildProviders(&b.cfg.SDKConfig)
|
configaccess.Register(&b.cfg.SDKConfig)
|
||||||
if err != nil {
|
accessManager.SetProviders(sdkaccess.RegisteredProviders())
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
accessManager.SetProviders(providers)
|
|
||||||
|
|
||||||
coreManager := b.coreManager
|
coreManager := b.coreManager
|
||||||
if coreManager == nil {
|
if coreManager == nil {
|
||||||
|
|||||||
@@ -740,6 +740,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
provider = "openai-compatibility"
|
provider = "openai-compatibility"
|
||||||
}
|
}
|
||||||
excluded := s.oauthExcludedModels(provider, authKind)
|
excluded := s.oauthExcludedModels(provider, authKind)
|
||||||
|
// 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 val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" {
|
||||||
|
excluded = strings.Split(val, ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
var models []*ModelInfo
|
var models []*ModelInfo
|
||||||
switch provider {
|
switch provider {
|
||||||
case "gemini":
|
case "gemini":
|
||||||
|
|||||||
65
sdk/cliproxy/service_excluded_models_test.go
Normal file
65
sdk/cliproxy/service_excluded_models_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ package config
|
|||||||
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
|
||||||
type SDKConfig = internalconfig.SDKConfig
|
type SDKConfig = internalconfig.SDKConfig
|
||||||
type AccessConfig = internalconfig.AccessConfig
|
|
||||||
type AccessProvider = internalconfig.AccessProvider
|
|
||||||
|
|
||||||
type Config = internalconfig.Config
|
type Config = internalconfig.Config
|
||||||
|
|
||||||
@@ -34,15 +32,9 @@ type OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel
|
|||||||
type TLS = internalconfig.TLSConfig
|
type TLS = internalconfig.TLSConfig
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AccessProviderTypeConfigAPIKey = internalconfig.AccessProviderTypeConfigAPIKey
|
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
|
||||||
DefaultAccessProviderName = internalconfig.DefaultAccessProviderName
|
|
||||||
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
|
|
||||||
return internalconfig.MakeInlineAPIKeyProvider(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }
|
func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }
|
||||||
|
|
||||||
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||||
|
|||||||
@@ -2590,6 +2590,135 @@ func TestThinkingE2EMatrix_Body(t *testing.T) {
|
|||||||
runThinkingTests(t, cases)
|
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.
|
// getTestModels returns the shared model definitions for E2E tests.
|
||||||
func getTestModels() []*registry.ModelInfo {
|
func getTestModels() []*registry.ModelInfo {
|
||||||
return []*registry.ModelInfo{
|
return []*registry.ModelInfo{
|
||||||
|
|||||||
Reference in New Issue
Block a user