mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-23 17:52:38 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8c4ffb5f | ||
|
|
1510bfcb6f | ||
|
|
c6bd91b86b | ||
|
|
349ddcaa89 | ||
|
|
938a799263 | ||
|
|
e17d4f8d98 | ||
|
|
c8cae1f74d | ||
|
|
0040d78496 | ||
|
|
896de027cc | ||
|
|
fc329ebf37 | ||
|
|
91841a5519 | ||
|
|
eaab1d6824 | ||
|
|
0cfe310df6 | ||
|
|
918b6955e4 | ||
|
|
532fbf00d4 | ||
|
|
45b6fffd7f | ||
|
|
5a3eb08739 | ||
|
|
0dff329162 | ||
|
|
49c1740b47 | ||
|
|
3fbee51e9f | ||
|
|
a3dc56d2a0 | ||
|
|
63643c44a1 | ||
|
|
1d93608dbe | ||
|
|
d125b7de92 | ||
|
|
d5654ee316 | ||
|
|
3b34521ad9 | ||
|
|
7197fb350b | ||
|
|
6e349bfcc7 | ||
|
|
234056072d | ||
|
|
76330f4bff | ||
|
|
d468eec6ec | ||
|
|
9bc6cc5b41 |
@@ -475,7 +475,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.
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ nonstream-keepalive-interval: 0
|
|||||||
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, kimi.
|
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot, 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:
|
# antigravity:
|
||||||
# - name: "rev19-uic3-1p"
|
# - name: "rev19-uic3-1p"
|
||||||
# alias: "gemini-2.5-computer-use-preview-10-2025"
|
# alias: "gemini-2.5-computer-use-preview-10-2025"
|
||||||
@@ -265,9 +265,6 @@ nonstream-keepalive-interval: 0
|
|||||||
# aistudio:
|
# aistudio:
|
||||||
# - name: "gemini-2.5-pro"
|
# - name: "gemini-2.5-pro"
|
||||||
# alias: "g2.5p"
|
# alias: "g2.5p"
|
||||||
# antigravity:
|
|
||||||
# - name: "gemini-3-pro-preview"
|
|
||||||
# alias: "g3p"
|
|
||||||
# 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` 保持一致,避免为更新访问策略而重启进程。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -122,7 +122,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
|
||||||
|
}
|
||||||
@@ -683,14 +683,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)
|
||||||
@@ -980,10 +983,6 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
|||||||
|
|
||||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
s.handlers.UpdateClients(&cfg.SDKConfig)
|
||||||
|
|
||||||
if !cfg.RemoteManagement.DisableControlPanel {
|
|
||||||
staticDir := managementasset.StaticDir(s.configFilePath)
|
|
||||||
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)
|
|
||||||
}
|
|
||||||
if s.mgmt != nil {
|
if s.mgmt != nil {
|
||||||
s.mgmt.SetConfig(cfg)
|
s.mgmt.SetConfig(cfg)
|
||||||
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
||||||
@@ -1062,14 +1061,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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -632,9 +632,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()
|
||||||
|
|
||||||
@@ -888,18 +885,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$")
|
||||||
@@ -987,7 +972,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 {
|
||||||
@@ -1055,16 +1040,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 {
|
||||||
@@ -1161,8 +1136,13 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
|
|||||||
|
|
||||||
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
|
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
|
||||||
// key order and comments of existing keys in dst. New keys are only added if their
|
// key order and comments of existing keys in dst. New keys are only added if their
|
||||||
// value is non-zero to avoid polluting the config with defaults.
|
// value is non-zero and not a known default to avoid polluting the config with defaults.
|
||||||
func mergeMappingPreserve(dst, src *yaml.Node) {
|
func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) {
|
||||||
|
var currentPath []string
|
||||||
|
if len(path) > 0 {
|
||||||
|
currentPath = path[0]
|
||||||
|
}
|
||||||
|
|
||||||
if dst == nil || src == nil {
|
if dst == nil || src == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1176,16 +1156,19 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
|
|||||||
sk := src.Content[i]
|
sk := src.Content[i]
|
||||||
sv := src.Content[i+1]
|
sv := src.Content[i+1]
|
||||||
idx := findMapKeyIndex(dst, sk.Value)
|
idx := findMapKeyIndex(dst, sk.Value)
|
||||||
|
childPath := appendPath(currentPath, sk.Value)
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
// Merge into existing value node (always update, even to zero values)
|
// Merge into existing value node (always update, even to zero values)
|
||||||
dv := dst.Content[idx+1]
|
dv := dst.Content[idx+1]
|
||||||
mergeNodePreserve(dv, sv)
|
mergeNodePreserve(dv, sv, childPath)
|
||||||
} else {
|
} else {
|
||||||
// New key: only add if value is non-zero to avoid polluting config with defaults
|
// New key: only add if value is non-zero and not a known default
|
||||||
if isZeroValueNode(sv) {
|
candidate := deepCopyNode(sv)
|
||||||
|
pruneKnownDefaultsInNewNode(childPath, candidate)
|
||||||
|
if isKnownDefaultValue(childPath, candidate) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
|
dst.Content = append(dst.Content, deepCopyNode(sk), candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1176,12 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
|
|||||||
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
||||||
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
||||||
// in-place by index.
|
// in-place by index.
|
||||||
func mergeNodePreserve(dst, src *yaml.Node) {
|
func mergeNodePreserve(dst, src *yaml.Node, path ...[]string) {
|
||||||
|
var currentPath []string
|
||||||
|
if len(path) > 0 {
|
||||||
|
currentPath = path[0]
|
||||||
|
}
|
||||||
|
|
||||||
if dst == nil || src == nil {
|
if dst == nil || src == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1202,7 +1190,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
|
|||||||
if dst.Kind != yaml.MappingNode {
|
if dst.Kind != yaml.MappingNode {
|
||||||
copyNodeShallow(dst, src)
|
copyNodeShallow(dst, src)
|
||||||
}
|
}
|
||||||
mergeMappingPreserve(dst, src)
|
mergeMappingPreserve(dst, src, currentPath)
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
// Preserve explicit null style if dst was null and src is empty sequence
|
// Preserve explicit null style if dst was null and src is empty sequence
|
||||||
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
|
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
|
||||||
@@ -1225,7 +1213,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
|
|||||||
dst.Content[i] = deepCopyNode(src.Content[i])
|
dst.Content[i] = deepCopyNode(src.Content[i])
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mergeNodePreserve(dst.Content[i], src.Content[i])
|
mergeNodePreserve(dst.Content[i], src.Content[i], currentPath)
|
||||||
if dst.Content[i] != nil && src.Content[i] != nil &&
|
if dst.Content[i] != nil && src.Content[i] != nil &&
|
||||||
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
|
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
|
||||||
pruneMissingMapKeys(dst.Content[i], src.Content[i])
|
pruneMissingMapKeys(dst.Content[i], src.Content[i])
|
||||||
@@ -1267,6 +1255,94 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appendPath appends a key to the path, returning a new slice to avoid modifying the original.
|
||||||
|
func appendPath(path []string, key string) []string {
|
||||||
|
if len(path) == 0 {
|
||||||
|
return []string{key}
|
||||||
|
}
|
||||||
|
newPath := make([]string, len(path)+1)
|
||||||
|
copy(newPath, path)
|
||||||
|
newPath[len(path)] = key
|
||||||
|
return newPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownDefaultValue returns true if the given node at the specified path
|
||||||
|
// represents a known default value that should not be written to the config file.
|
||||||
|
// This prevents non-zero defaults from polluting the config.
|
||||||
|
func isKnownDefaultValue(path []string, node *yaml.Node) bool {
|
||||||
|
// First check if it's a zero value
|
||||||
|
if isZeroValueNode(node) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match known non-zero defaults by exact dotted path.
|
||||||
|
if len(path) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := strings.Join(path, ".")
|
||||||
|
|
||||||
|
// Check string defaults
|
||||||
|
if node.Kind == yaml.ScalarNode && node.Tag == "!!str" {
|
||||||
|
switch fullPath {
|
||||||
|
case "pprof.addr":
|
||||||
|
return node.Value == DefaultPprofAddr
|
||||||
|
case "remote-management.panel-github-repository":
|
||||||
|
return node.Value == DefaultPanelGitHubRepository
|
||||||
|
case "routing.strategy":
|
||||||
|
return node.Value == "round-robin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check integer defaults
|
||||||
|
if node.Kind == yaml.ScalarNode && node.Tag == "!!int" {
|
||||||
|
switch fullPath {
|
||||||
|
case "error-logs-max-files":
|
||||||
|
return node.Value == "10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneKnownDefaultsInNewNode removes default-valued descendants from a new node
|
||||||
|
// before it is appended into the destination YAML tree.
|
||||||
|
func pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) {
|
||||||
|
if node == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch node.Kind {
|
||||||
|
case yaml.MappingNode:
|
||||||
|
filtered := make([]*yaml.Node, 0, len(node.Content))
|
||||||
|
for i := 0; i+1 < len(node.Content); i += 2 {
|
||||||
|
keyNode := node.Content[i]
|
||||||
|
valueNode := node.Content[i+1]
|
||||||
|
if keyNode == nil || valueNode == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
childPath := appendPath(path, keyNode.Value)
|
||||||
|
if isKnownDefaultValue(childPath, valueNode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pruneKnownDefaultsInNewNode(childPath, valueNode)
|
||||||
|
if (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) &&
|
||||||
|
len(valueNode.Content) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, keyNode, valueNode)
|
||||||
|
}
|
||||||
|
node.Content = filtered
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
for _, child := range node.Content {
|
||||||
|
pruneKnownDefaultsInNewNode(path, child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// isZeroValueNode returns true if the YAML node represents a zero/default value
|
// isZeroValueNode returns true if the YAML node represents a zero/default value
|
||||||
// that should not be written as a new key to preserve config cleanliness.
|
// that should not be written as a new key to preserve config cleanliness.
|
||||||
// For mappings and sequences, recursively checks if all children are zero values.
|
// For mappings and sequences, recursively checks if all children are zero values.
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -28,6 +29,7 @@ const (
|
|||||||
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
|
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
|
||||||
managementAssetName = "management.html"
|
managementAssetName = "management.html"
|
||||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||||
|
managementSyncMinInterval = 30 * time.Second
|
||||||
updateCheckInterval = 3 * time.Hour
|
updateCheckInterval = 3 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,11 +39,10 @@ const ManagementFileName = managementAssetName
|
|||||||
var (
|
var (
|
||||||
lastUpdateCheckMu sync.Mutex
|
lastUpdateCheckMu sync.Mutex
|
||||||
lastUpdateCheckTime time.Time
|
lastUpdateCheckTime time.Time
|
||||||
|
|
||||||
currentConfigPtr atomic.Pointer[config.Config]
|
currentConfigPtr atomic.Pointer[config.Config]
|
||||||
disableControlPanel atomic.Bool
|
|
||||||
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.
|
||||||
@@ -50,16 +51,7 @@ func SetCurrentConfig(cfg *config.Config) {
|
|||||||
currentConfigPtr.Store(nil)
|
currentConfigPtr.Store(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prevDisabled := disableControlPanel.Load()
|
|
||||||
currentConfigPtr.Store(cfg)
|
currentConfigPtr.Store(cfg)
|
||||||
disableControlPanel.Store(cfg.RemoteManagement.DisableControlPanel)
|
|
||||||
|
|
||||||
if prevDisabled && !cfg.RemoteManagement.DisableControlPanel {
|
|
||||||
lastUpdateCheckMu.Lock()
|
|
||||||
lastUpdateCheckTime = time.Time{}
|
|
||||||
lastUpdateCheckMu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date.
|
// StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date.
|
||||||
@@ -92,7 +84,7 @@ func runAutoUpdater(ctx context.Context) {
|
|||||||
log.Debug("management asset auto-updater skipped: config not yet available")
|
log.Debug("management asset auto-updater skipped: config not yet available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if disableControlPanel.Load() {
|
if cfg.RemoteManagement.DisableControlPanel {
|
||||||
log.Debug("management asset auto-updater skipped: control panel disabled")
|
log.Debug("management asset auto-updater skipped: control panel disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -181,103 +173,106 @@ 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.
|
||||||
// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes.
|
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool {
|
||||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) {
|
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
if disableControlPanel.Load() {
|
|
||||||
log.Debug("management asset sync skipped: control panel disabled by configuration")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
localPath := filepath.Join(staticDir, managementAssetName)
|
localPath := filepath.Join(staticDir, managementAssetName)
|
||||||
localFileMissing := false
|
|
||||||
if _, errStat := os.Stat(localPath); errStat != nil {
|
|
||||||
if errors.Is(errStat, os.ErrNotExist) {
|
|
||||||
localFileMissing = true
|
|
||||||
} else {
|
|
||||||
log.WithError(errStat).Debug("failed to stat local management asset")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting: check only once every 3 hours
|
_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {
|
||||||
lastUpdateCheckMu.Lock()
|
lastUpdateCheckMu.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
|
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
||||||
if timeSinceLastCheck < updateCheckInterval {
|
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()
|
lastUpdateCheckMu.Unlock()
|
||||||
log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -277,6 +277,18 @@ func GetGitHubCopilotModels() []*ModelInfo {
|
|||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
SupportedEndpoints: []string{"/chat/completions"},
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "claude-opus-4.6",
|
||||||
|
Object: "model",
|
||||||
|
Created: now,
|
||||||
|
OwnedBy: "github-copilot",
|
||||||
|
Type: "github-copilot",
|
||||||
|
DisplayName: "Claude Opus 4.6",
|
||||||
|
Description: "Anthropic Claude Opus 4.6 via GitHub Copilot",
|
||||||
|
ContextLength: 200000,
|
||||||
|
MaxCompletionTokens: 64000,
|
||||||
|
SupportedEndpoints: []string{"/chat/completions"},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "claude-sonnet-4",
|
ID: "claude-sonnet-4",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
|
|||||||
@@ -866,7 +866,7 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig {
|
|||||||
"gemini-3-flash": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}},
|
"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": {},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,11 +34,11 @@ const (
|
|||||||
maxScannerBufferSize = 20_971_520
|
maxScannerBufferSize = 20_971_520
|
||||||
|
|
||||||
// Copilot API header values.
|
// Copilot API header values.
|
||||||
copilotUserAgent = "GithubCopilot/1.0"
|
copilotUserAgent = "GitHubCopilotChat/0.35.0"
|
||||||
copilotEditorVersion = "vscode/1.100.0"
|
copilotEditorVersion = "vscode/1.107.0"
|
||||||
copilotPluginVersion = "copilot/1.300.0"
|
copilotPluginVersion = "copilot-chat/0.35.0"
|
||||||
copilotIntegrationID = "vscode-chat"
|
copilotIntegrationID = "vscode-chat"
|
||||||
copilotOpenAIIntent = "conversation-panel"
|
copilotOpenAIIntent = "conversation-edits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
|
// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
|
||||||
@@ -77,7 +78,7 @@ func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxy
|
|||||||
if errToken != nil {
|
if errToken != nil {
|
||||||
return errToken
|
return errToken
|
||||||
}
|
}
|
||||||
e.applyHeaders(req, apiToken)
|
e.applyHeaders(req, apiToken, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.
|
|||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||||
body = e.normalizeModel(req.Model, body)
|
body = e.normalizeModel(req.Model, body)
|
||||||
|
body = flattenAssistantContent(body)
|
||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", false)
|
body, _ = sjson.SetBytes(body, "stream", false)
|
||||||
@@ -133,7 +135,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
e.applyHeaders(httpReq, apiToken)
|
e.applyHeaders(httpReq, apiToken, body)
|
||||||
|
|
||||||
// Add Copilot-Vision-Request header if the request contains vision content
|
// Add Copilot-Vision-Request header if the request contains vision content
|
||||||
if detectVisionContent(body) {
|
if detectVisionContent(body) {
|
||||||
@@ -225,6 +227,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox
|
|||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||||
body = e.normalizeModel(req.Model, body)
|
body = e.normalizeModel(req.Model, body)
|
||||||
|
body = flattenAssistantContent(body)
|
||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
@@ -242,7 +245,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.applyHeaders(httpReq, apiToken)
|
e.applyHeaders(httpReq, apiToken, body)
|
||||||
|
|
||||||
// Add Copilot-Vision-Request header if the request contains vision content
|
// Add Copilot-Vision-Request header if the request contains vision content
|
||||||
if detectVisionContent(body) {
|
if detectVisionContent(body) {
|
||||||
@@ -414,7 +417,7 @@ func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *clipro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyHeaders sets the required headers for GitHub Copilot API requests.
|
// applyHeaders sets the required headers for GitHub Copilot API requests.
|
||||||
func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
|
func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, body []byte) {
|
||||||
r.Header.Set("Content-Type", "application/json")
|
r.Header.Set("Content-Type", "application/json")
|
||||||
r.Header.Set("Authorization", "Bearer "+apiToken)
|
r.Header.Set("Authorization", "Bearer "+apiToken)
|
||||||
r.Header.Set("Accept", "application/json")
|
r.Header.Set("Accept", "application/json")
|
||||||
@@ -424,6 +427,20 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) {
|
|||||||
r.Header.Set("Openai-Intent", copilotOpenAIIntent)
|
r.Header.Set("Openai-Intent", copilotOpenAIIntent)
|
||||||
r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
|
r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
|
||||||
r.Header.Set("X-Request-Id", uuid.NewString())
|
r.Header.Set("X-Request-Id", uuid.NewString())
|
||||||
|
|
||||||
|
initiator := "user"
|
||||||
|
if len(body) > 0 {
|
||||||
|
if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
|
||||||
|
arr := messages.Array()
|
||||||
|
if len(arr) > 0 {
|
||||||
|
lastRole := arr[len(arr)-1].Get("role").String()
|
||||||
|
if lastRole != "" && lastRole != "user" {
|
||||||
|
initiator = "agent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Header.Set("X-Initiator", initiator)
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectVisionContent checks if the request body contains vision/image content.
|
// detectVisionContent checks if the request body contains vision/image content.
|
||||||
@@ -464,6 +481,38 @@ func useGitHubCopilotResponsesEndpoint(sourceFormat sdktranslator.Format) bool {
|
|||||||
return sourceFormat.String() == "openai-response"
|
return sourceFormat.String() == "openai-response"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// flattenAssistantContent converts assistant message content from array format
|
||||||
|
// to a joined string. GitHub Copilot requires assistant content as a string;
|
||||||
|
// sending it as an array causes Claude models to re-answer all previous prompts.
|
||||||
|
func flattenAssistantContent(body []byte) []byte {
|
||||||
|
messages := gjson.GetBytes(body, "messages")
|
||||||
|
if !messages.Exists() || !messages.IsArray() {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
result := body
|
||||||
|
for i, msg := range messages.Array() {
|
||||||
|
if msg.Get("role").String() != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := msg.Get("content")
|
||||||
|
if !content.Exists() || !content.IsArray() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var textParts []string
|
||||||
|
for _, part := range content.Array() {
|
||||||
|
if part.Get("type").String() == "text" {
|
||||||
|
if t := part.Get("text").String(); t != "" {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
joined := strings.Join(textParts, "")
|
||||||
|
path := fmt.Sprintf("messages.%d.content", i)
|
||||||
|
result, _ = sjson.SetBytes(result, path, joined)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// isHTTPSuccess checks if the status code indicates success (2xx).
|
// isHTTPSuccess checks if the status code indicates success (2xx).
|
||||||
func isHTTPSuccess(statusCode int) bool {
|
func isHTTPSuccess(statusCode int) bool {
|
||||||
return statusCode >= 200 && statusCode < 300
|
return statusCode >= 200 && statusCode < 300
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
|
|||||||
if role == "developer" {
|
if role == "developer" {
|
||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
message := `{"role":"","content":""}`
|
message := `{"role":"","content":[]}`
|
||||||
message, _ = sjson.Set(message, "role", role)
|
message, _ = sjson.Set(message, "role", role)
|
||||||
|
|
||||||
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
||||||
@@ -84,20 +84,16 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case "input_text":
|
case "input_text", "output_text":
|
||||||
text := contentItem.Get("text").String()
|
text := contentItem.Get("text").String()
|
||||||
if messageContent != "" {
|
contentPart := `{"type":"text","text":""}`
|
||||||
messageContent += "\n" + text
|
contentPart, _ = sjson.Set(contentPart, "text", text)
|
||||||
} else {
|
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
|
||||||
messageContent = text
|
case "input_image":
|
||||||
}
|
imageURL := contentItem.Get("image_url").String()
|
||||||
case "output_text":
|
contentPart := `{"type":"image_url","image_url":{"url":""}}`
|
||||||
text := contentItem.Get("text").String()
|
contentPart, _ = sjson.Set(contentPart, "image_url.url", imageURL)
|
||||||
if messageContent != "" {
|
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
|
||||||
messageContent += "\n" + text
|
|
||||||
} else {
|
|
||||||
messageContent = text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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