Compare commits

..

15 Commits

Author SHA1 Message Date
Luis Pater
0040d78496 refactor(sdk): simplify provider lifecycle and registration logic 2026-02-10 15:39:26 +08:00
hkfires
896de027cc docs(config): reorder antigravity model alias example 2026-02-10 10:13:54 +08:00
hkfires
fc329ebf37 docs(config): simplify oauth model alias example 2026-02-10 10:12:28 +08:00
Luis Pater
eaab1d6824 Merge pull request #1506 from masrurimz/fix-sse-model-mapping
fix(amp): rewrite response.model in Responses API SSE events
2026-02-10 02:08:11 +08:00
Muhammad Zahid Masruri
0cfe310df6 ci: retrigger workflows
Amp-Thread-ID: https://ampcode.com/threads/T-019c264f-1cb9-7420-a68b-876030db6716
2026-02-10 00:09:11 +07:00
Muhammad Zahid Masruri
918b6955e4 fix(amp): rewrite model name in response.model for Responses API SSE events
The ResponseRewriter's modelFieldPaths was missing 'response.model',
causing the mapped model name to leak through SSE streaming events
(response.created, response.in_progress, response.completed) in the
OpenAI Responses API (/v1/responses).

This caused Amp CLI to report 'Unknown OpenAI model' errors when
model mapping was active (e.g., gpt-5.2-codex -> gpt-5.3-codex),
because the mapped name reached Amp's backend via telemetry.

Also sorted modelFieldPaths alphabetically per review feedback
and added regression tests for all rewrite paths.

Fixes #1463
2026-02-09 23:52:59 +07:00
Luis Pater
5a3eb08739 Merge pull request #1502 from router-for-me/iflow
feat(executor): add session ID and HMAC-SHA256 signature generation for iFlow API requests
2026-02-09 19:56:12 +08:00
Luis Pater
0dff329162 Merge pull request #1492 from router-for-me/management
fix(management): ensure management.html is available synchronously and improve asset sync handling
2026-02-09 19:55:21 +08:00
hkfires
49c1740b47 feat(executor): add session ID and HMAC-SHA256 signature generation for iFlow API requests 2026-02-09 19:29:42 +08:00
hkfires
3fbee51e9f fix(management): ensure management.html is available synchronously and improve asset sync handling 2026-02-09 08:32:58 +08:00
Luis Pater
63643c44a1 Fixed: #1484
fix(translator): restructure message content handling to support multiple content types

- Consolidated `input_text` and `output_text` handling into a single case.
- Added support for processing `input_image` content with associated URLs.
2026-02-09 02:05:38 +08:00
Luis Pater
3b34521ad9 Merge pull request #1479 from router-for-me/management
refactor(management): streamline control panel management and implement sync throttling
2026-02-08 20:37:29 +08:00
hkfires
7197fb350b fix(config): prune default descendants when merging new yaml nodes 2026-02-08 19:05:52 +08:00
hkfires
6e349bfcc7 fix(config): avoid writing known defaults during merge 2026-02-08 18:47:44 +08:00
hkfires
234056072d refactor(management): streamline control panel management and implement sync throttling 2026-02-08 10:42:49 +08:00
22 changed files with 754 additions and 673 deletions

View File

@@ -445,7 +445,7 @@ func main() {
}
// Register built-in access providers before constructing services.
configaccess.Register()
configaccess.Register(&cfg.SDKConfig)
// Handle different command modes based on the provided flags.

View File

@@ -224,22 +224,7 @@ nonstream-keepalive-interval: 0
# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kimi.
# NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode.
# You can repeat the same name with different aliases to expose multiple client model names.
oauth-model-alias:
antigravity:
- name: "rev19-uic3-1p"
alias: "gemini-2.5-computer-use-preview-10-2025"
- name: "gemini-3-pro-image"
alias: "gemini-3-pro-image-preview"
- name: "gemini-3-pro-high"
alias: "gemini-3-pro-preview"
- name: "gemini-3-flash"
alias: "gemini-3-flash-preview"
- name: "claude-sonnet-4-5"
alias: "gemini-claude-sonnet-4-5"
- name: "claude-sonnet-4-5-thinking"
alias: "gemini-claude-sonnet-4-5-thinking"
- name: "claude-opus-4-5-thinking"
alias: "gemini-claude-opus-4-5-thinking"
# oauth-model-alias:
# gemini-cli:
# - name: "gemini-2.5-pro" # original model name under this channel
# alias: "g2.5p" # client-visible alias
@@ -250,6 +235,9 @@ oauth-model-alias:
# aistudio:
# - name: "gemini-2.5-pro"
# alias: "g2.5p"
# antigravity:
# - name: "gemini-3-pro-high"
# alias: "gemini-3-pro-preview"
# claude:
# - name: "claude-sonnet-4-5-20250929"
# alias: "cs4.5"

View File

@@ -7,80 +7,71 @@ The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inb
```go
import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
```
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
## Provider Registry
Providers are registered globally and then attached to a `Manager` as a snapshot:
- `RegisterProvider(type, provider)` installs a pre-initialized provider instance.
- Registration order is preserved the first time each `type` is seen.
- `RegisteredProviders()` returns the providers in that order.
## Manager Lifecycle
```go
manager := sdkaccess.NewManager()
providers, err := sdkaccess.BuildProviders(cfg)
if err != nil {
return err
}
manager.SetProviders(providers)
manager.SetProviders(sdkaccess.RegisteredProviders())
```
* `NewManager` constructs an empty manager.
* `SetProviders` replaces the provider slice using a defensive copy.
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider.
If the manager itself is `nil` or no providers are configured, the call returns `nil, nil`, allowing callers to treat access control as disabled.
## Authenticating Requests
```go
result, err := manager.Authenticate(ctx, req)
result, authErr := manager.Authenticate(ctx, req)
switch {
case err == nil:
case authErr == nil:
// Authentication succeeded; result describes the provider and principal.
case errors.Is(err, sdkaccess.ErrNoCredentials):
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
// No recognizable credentials were supplied.
case errors.Is(err, sdkaccess.ErrInvalidCredential):
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
// Supplied credentials were present but rejected.
default:
// Transport-level failure was returned by a provider.
// Internal/transport failure was returned by a provider.
}
```
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting.
If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors.
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that return `AuthErrorCodeNotHandled`, and aggregates `AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` for a final result.
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
## Configuration Layout
## Built-in `config-api-key` Provider
The manager expects access providers under the `auth.providers` key inside `config.yaml`:
The proxy includes one built-in access provider:
- `config-api-key`: Validates API keys declared under top-level `api-keys`.
- Credential sources: `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, `?key=`, `?auth_token=`
- Metadata: `Result.Metadata["source"]` is set to the matched source label.
In the CLI server and `sdk/cliproxy`, this provider is registered automatically based on the loaded configuration.
```yaml
auth:
providers:
- name: inline-api
type: config-api-key
api-keys:
- sk-test-123
- sk-prod-456
api-keys:
- sk-test-123
- sk-prod-456
```
Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options.
## Loading Providers from External Go Modules
### Loading providers from external SDK modules
To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect:
```yaml
auth:
providers:
- name: partner-auth
type: partner-token
sdk: github.com/acme/xplatform/sdk/access/providers/partner
config:
region: us-west-2
audience: cli-proxy
```
To consume a provider shipped in another Go module, import it for its registration side effect:
```go
import (
@@ -89,19 +80,11 @@ import (
)
```
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
## Built-in Providers
The SDK ships with one provider out of the box:
- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found.
Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before you call `RegisteredProviders()` (or before `cliproxy.NewBuilder().Build()`).
### Metadata and auditing
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing.
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, `query-key`, `query-auth-token`). Populate this map in custom providers to enrich logs and downstream auditing.
## Writing Custom Providers
@@ -110,13 +93,13 @@ type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
token := r.Header.Get("X-Custom")
if token == "" {
return nil, sdkaccess.ErrNoCredentials
return nil, sdkaccess.NewNotHandledError()
}
if token != "expected" {
return nil, sdkaccess.ErrInvalidCredential
return nil, sdkaccess.NewInvalidCredentialError()
}
return &sdkaccess.Result{
Provider: p.Identifier(),
@@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd
}
func init() {
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
return &customProvider{}, nil
})
sdkaccess.RegisterProvider("custom", &customProvider{})
}
```
A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs.
A provider must implement `Identifier()` and `Authenticate()`. To make it available to the access manager, call `RegisterProvider` inside `init` with an initialized provider instance.
## Error Semantics
- `ErrNoCredentials`: no credentials were present or recognized by any provider.
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
- `NewNoCredentialsError()` (`AuthErrorCodeNoCredentials`): no credentials were present or recognized. (HTTP 401)
- `NewInvalidCredentialError()` (`AuthErrorCodeInvalidCredential`): credentials were present but rejected. (HTTP 401)
- `NewNotHandledError()` (`AuthErrorCodeNotHandled`): fall through to the next provider.
- `NewInternalAuthError(message, cause)` (`AuthErrorCodeInternal`): transport/system failure. (HTTP 500)
Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked.
Errors propagate immediately to the caller unless they are classified as `not_handled` / `no_credentials` / `invalid_credential` and can be aggregated by the manager.
## Integration with cliproxy Service
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers:
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a manager lets you reuse the same instance in your host process:
```go
coreCfg, _ := config.LoadConfig("config.yaml")
providers, _ := sdkaccess.BuildProviders(coreCfg)
manager := sdkaccess.NewManager()
manager.SetProviders(providers)
accessManager := sdkaccess.NewManager()
svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg).
WithAccessManager(manager).
WithConfigPath("config.yaml").
WithRequestAccessManager(accessManager).
Build()
```
The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary.
Register any custom providers (typically via blank imports) before calling `Build()` so they are present in the global registry snapshot.
### Hot reloading providers
### Hot reloading
When configuration changes, rebuild providers and swap them into the manager:
When configuration changes, refresh any config-backed providers and then reset the manager's provider chain:
```go
providers, err := sdkaccess.BuildProviders(newCfg)
if err != nil {
log.Errorf("reload auth providers failed: %v", err)
return
}
accessManager.SetProviders(providers)
// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
configaccess.Register(&newCfg.SDKConfig)
accessManager.SetProviders(sdkaccess.RegisteredProviders())
```
This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process.
This mirrors the behaviour in `internal/access.ApplyAccessProviders`, enabling runtime updates without restarting the process.

View File

@@ -7,80 +7,71 @@
```go
import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
```
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
## Provider Registry
访问提供者是全局注册,然后以快照形式挂到 `Manager` 上:
- `RegisterProvider(type, provider)` 注册一个已经初始化好的 provider 实例。
- 每个 `type` 第一次出现时会记录其注册顺序。
- `RegisteredProviders()` 会按该顺序返回 provider 列表。
## 管理器生命周期
```go
manager := sdkaccess.NewManager()
providers, err := sdkaccess.BuildProviders(cfg)
if err != nil {
return err
}
manager.SetProviders(providers)
manager.SetProviders(sdkaccess.RegisteredProviders())
```
- `NewManager` 创建空管理器。
- `SetProviders` 替换提供者切片并做防御性拷贝。
- `Providers` 返回适合并发读取的快照。
- `BuildProviders``config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
如果管理器本身为 `nil` 或未配置任何 provider调用会返回 `nil, nil`,可视为关闭访问控制。
## 认证请求
```go
result, err := manager.Authenticate(ctx, req)
result, authErr := manager.Authenticate(ctx, req)
switch {
case err == nil:
case authErr == nil:
// Authentication succeeded; result carries provider and principal.
case errors.Is(err, sdkaccess.ErrNoCredentials):
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeNoCredentials):
// No recognizable credentials were supplied.
case errors.Is(err, sdkaccess.ErrInvalidCredential):
case sdkaccess.IsAuthErrorCode(authErr, sdkaccess.AuthErrorCodeInvalidCredential):
// Credentials were present but rejected.
default:
// Provider surfaced a transport-level failure.
}
```
`Manager.Authenticate`配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials` `ErrInvalidCredential`会在遍历结束后汇总给调用方。
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
`Manager.Authenticate` 按顺序遍历 provider遇到成功立即返回,`AuthErrorCodeNotHandled` 会继续尝试下一个;`AuthErrorCodeNoCredentials` / `AuthErrorCodeInvalidCredential` 会在遍历结束后汇总给调用方。
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
## 配置结构
## 内建 `config-api-key` Provider
`config.yaml``auth.providers` 下定义访问提供者:
代理内置一个访问提供者:
- `config-api-key`:校验 `config.yaml` 顶层的 `api-keys`
- 凭证来源:`Authorization: Bearer``X-Goog-Api-Key``X-Api-Key``?key=``?auth_token=`
- 元数据:`Result.Metadata["source"]` 会写入匹配到的来源标识
在 CLI 服务端与 `sdk/cliproxy` 中,该 provider 会根据加载到的配置自动注册。
```yaml
auth:
providers:
- name: inline-api
type: config-api-key
api-keys:
- sk-test-123
- sk-prod-456
api-keys:
- sk-test-123
- sk-prod-456
```
条目映射到 `config.AccessProvider``name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
## 引入外部 Go 模块提供者
### 引入外部 SDK 提供者
若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
```yaml
auth:
providers:
- name: partner-auth
type: partner-token
sdk: github.com/acme/xplatform/sdk/access/providers/partner
config:
region: us-west-2
audience: cli-proxy
```
若要消费其它 Go 模块输出的访问提供者,直接用空白标识符导入以触发其 `init` 注册即可:
```go
import (
@@ -89,19 +80,11 @@ import (
)
```
通过空白标识符导入可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`
## 内建提供者
当前 SDK 默认内置:
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer``X-Goog-Api-Key``X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`
导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
空白导入可确保 `init` 先执行,从而在你调用 `RegisteredProviders()`(或 `cliproxy.NewBuilder().Build()`)之前完成 `sdkaccess.RegisterProvider`
### 元数据与审计
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization``x-goog-api-key``x-api-key``query-key`)。自定义提供者同样可以填充该 Map以便丰富日志与审计场景。
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization``x-goog-api-key``x-api-key``query-key``query-auth-token`)。自定义提供者同样可以填充该 Map以便丰富日志与审计场景。
## 编写自定义提供者
@@ -110,13 +93,13 @@ type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
token := r.Header.Get("X-Custom")
if token == "" {
return nil, sdkaccess.ErrNoCredentials
return nil, sdkaccess.NewNotHandledError()
}
if token != "expected" {
return nil, sdkaccess.ErrInvalidCredential
return nil, sdkaccess.NewInvalidCredentialError()
}
return &sdkaccess.Result{
Provider: p.Identifier(),
@@ -126,51 +109,46 @@ func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sd
}
func init() {
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
return &customProvider{}, nil
})
sdkaccess.RegisterProvider("custom", &customProvider{})
}
```
自定义提供者需要实现 `Identifier()``Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置
自定义提供者需要实现 `Identifier()``Authenticate()`。在 `init`用已初始化实例调用 `RegisterProvider` 注册到全局 registry
## 错误语义
- `ErrNoCredentials`:任何提供者都未识别到凭证。
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计
- `NewNoCredentialsError()``AuthErrorCodeNoCredentials`未提供或未识别到凭证。HTTP 401
- `NewInvalidCredentialError()``AuthErrorCodeInvalidCredential`凭证存在但校验失败。HTTP 401
- `NewNotHandledError()``AuthErrorCodeNotHandled`:告诉管理器跳到下一个 provider
- `NewInternalAuthError(message, cause)``AuthErrorCodeInternal`):网络/系统错误。HTTP 500
自定义错误(例如网络异常)会马上冒泡返回。
除可汇总的 `not_handled` / `no_credentials` / `invalid_credential` 外,其它错误会立即冒泡返回。
## 与 cliproxy 集成
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果希望在宿主进程里复用同一个 `Manager` 实例,可传入自定义管理器:
```go
coreCfg, _ := config.LoadConfig("config.yaml")
providers, _ := sdkaccess.BuildProviders(coreCfg)
manager := sdkaccess.NewManager()
manager.SetProviders(providers)
accessManager := sdkaccess.NewManager()
svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg).
WithAccessManager(manager).
WithConfigPath("config.yaml").
WithRequestAccessManager(accessManager).
Build()
```
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验
请在调用 `Build()` 之前完成自定义 provider 的注册(通常通过空白导入触发 `init`),以确保它们被包含在全局 registry 的快照中
### 动态热更新提供者
当配置发生变化时,可以重新构建提供者并替换当前列表
当配置发生变化时,刷新依赖配置的 provider然后重置 manager 的 provider 链
```go
providers, err := sdkaccess.BuildProviders(newCfg)
if err != nil {
log.Errorf("reload auth providers failed: %v", err)
return
}
accessManager.SetProviders(providers)
// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access
configaccess.Register(&newCfg.SDKConfig)
accessManager.SetProviders(sdkaccess.RegisteredProviders())
```
这一流程与 `cliproxy.Service.refreshAccessProviders``api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。
这一流程与 `internal/access.ApplyAccessProviders` 保持一致,避免为更新访问策略而重启进程。

2
go.mod
View File

@@ -22,6 +22,7 @@ require (
golang.org/x/crypto v0.45.0
golang.org/x/net v0.47.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.18.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)
@@ -69,7 +70,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect

View File

@@ -4,19 +4,28 @@ import (
"context"
"net/http"
"strings"
"sync"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
var registerOnce sync.Once
// Register ensures the config-access provider is available to the access manager.
func Register() {
registerOnce.Do(func() {
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
})
func Register(cfg *sdkconfig.SDKConfig) {
if cfg == nil {
sdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey)
return
}
keys := normalizeKeys(cfg.APIKeys)
if len(keys) == 0 {
sdkaccess.UnregisterProvider(sdkaccess.AccessProviderTypeConfigAPIKey)
return
}
sdkaccess.RegisterProvider(
sdkaccess.AccessProviderTypeConfigAPIKey,
newProvider(sdkaccess.DefaultAccessProviderName, keys),
)
}
type provider struct {
@@ -24,34 +33,31 @@ type provider struct {
keys map[string]struct{}
}
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
name := cfg.Name
if name == "" {
name = sdkconfig.DefaultAccessProviderName
func newProvider(name string, keys []string) *provider {
providerName := strings.TrimSpace(name)
if providerName == "" {
providerName = sdkaccess.DefaultAccessProviderName
}
keys := make(map[string]struct{}, len(cfg.APIKeys))
for _, key := range cfg.APIKeys {
if key == "" {
continue
}
keys[key] = struct{}{}
keySet := make(map[string]struct{}, len(keys))
for _, key := range keys {
keySet[key] = struct{}{}
}
return &provider{name: name, keys: keys}, nil
return &provider{name: providerName, keys: keySet}
}
func (p *provider) Identifier() string {
if p == nil || p.name == "" {
return sdkconfig.DefaultAccessProviderName
return sdkaccess.DefaultAccessProviderName
}
return p.name
}
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) {
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, *sdkaccess.AuthError) {
if p == nil {
return nil, sdkaccess.ErrNotHandled
return nil, sdkaccess.NewNotHandledError()
}
if len(p.keys) == 0 {
return nil, sdkaccess.ErrNotHandled
return nil, sdkaccess.NewNotHandledError()
}
authHeader := r.Header.Get("Authorization")
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
@@ -63,7 +69,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
queryAuthToken = r.URL.Query().Get("auth_token")
}
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" && queryAuthToken == "" {
return nil, sdkaccess.ErrNoCredentials
return nil, sdkaccess.NewNoCredentialsError()
}
apiKey := extractBearerToken(authHeader)
@@ -94,7 +100,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
}
}
return nil, sdkaccess.ErrInvalidCredential
return nil, sdkaccess.NewInvalidCredentialError()
}
func extractBearerToken(header string) string {
@@ -110,3 +116,26 @@ func extractBearerToken(header string) string {
}
return strings.TrimSpace(parts[1])
}
func normalizeKeys(keys []string) []string {
if len(keys) == 0 {
return nil
}
normalized := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
if _, exists := seen[trimmedKey]; exists {
continue
}
seen[trimmedKey] = struct{}{}
normalized = append(normalized, trimmedKey)
}
if len(normalized) == 0 {
return nil
}
return normalized
}

View File

@@ -6,9 +6,9 @@ import (
"sort"
"strings"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
)
@@ -17,26 +17,26 @@ import (
// ordered provider slice along with the identifiers of providers that were added, updated, or
// removed compared to the previous configuration.
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
_ = oldCfg
if newCfg == nil {
return nil, nil, nil, nil, nil
}
result = sdkaccess.RegisteredProviders()
existingMap := make(map[string]sdkaccess.Provider, len(existing))
for _, provider := range existing {
if provider == nil {
providerID := identifierFromProvider(provider)
if providerID == "" {
continue
}
existingMap[provider.Identifier()] = provider
existingMap[providerID] = provider
}
oldCfgMap := accessProviderMap(oldCfg)
newEntries := collectProviderEntries(newCfg)
result = make([]sdkaccess.Provider, 0, len(newEntries))
finalIDs := make(map[string]struct{}, len(newEntries))
finalIDs := make(map[string]struct{}, len(result))
isInlineProvider := func(id string) bool {
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
return strings.EqualFold(id, sdkaccess.DefaultAccessProviderName)
}
appendChange := func(list *[]string, id string) {
if isInlineProvider(id) {
@@ -45,85 +45,28 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov
*list = append(*list, id)
}
for _, providerCfg := range newEntries {
key := providerIdentifier(providerCfg)
if key == "" {
for _, provider := range result {
providerID := identifierFromProvider(provider)
if providerID == "" {
continue
}
finalIDs[providerID] = struct{}{}
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
if oldCfgProvider, ok := oldCfgMap[key]; ok {
isAliased := oldCfgProvider == providerCfg
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}
continue
}
}
existingProvider, exists := existingMap[providerID]
if !exists {
appendChange(&added, providerID)
continue
}
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
if buildErr != nil {
return nil, nil, nil, nil, buildErr
}
if _, ok := oldCfgMap[key]; ok {
if _, existed := existingMap[key]; existed {
appendChange(&updated, key)
} else {
appendChange(&added, key)
}
} else {
appendChange(&added, key)
}
result = append(result, provider)
finalIDs[key] = struct{}{}
}
if len(result) == 0 {
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
key := providerIdentifier(inline)
if key != "" {
if oldCfgProvider, ok := oldCfgMap[key]; ok {
if providerConfigEqual(oldCfgProvider, inline) {
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}
goto inlineDone
}
}
}
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
if buildErr != nil {
return nil, nil, nil, nil, buildErr
}
if _, existed := existingMap[key]; existed {
appendChange(&updated, key)
} else if _, hadOld := oldCfgMap[key]; hadOld {
appendChange(&updated, key)
} else {
appendChange(&added, key)
}
result = append(result, provider)
finalIDs[key] = struct{}{}
}
}
inlineDone:
}
removedSet := make(map[string]struct{})
for id := range existingMap {
if _, ok := finalIDs[id]; !ok {
if isInlineProvider(id) {
continue
}
removedSet[id] = struct{}{}
if !providerInstanceEqual(existingProvider, provider) {
appendChange(&updated, providerID)
}
}
removed = make([]string, 0, len(removedSet))
for id := range removedSet {
removed = append(removed, id)
for providerID := range existingMap {
if _, exists := finalIDs[providerID]; exists {
continue
}
appendChange(&removed, providerID)
}
sort.Strings(added)
@@ -142,6 +85,7 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con
}
existing := manager.Providers()
configaccess.Register(&newCfg.SDKConfig)
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
if err != nil {
log.Errorf("failed to reconcile request auth providers: %v", err)
@@ -160,111 +104,24 @@ func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Con
return false, nil
}
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
result := make(map[string]*sdkConfig.AccessProvider)
if cfg == nil {
return result
}
for i := range cfg.Access.Providers {
providerCfg := &cfg.Access.Providers[i]
if providerCfg.Type == "" {
continue
}
key := providerIdentifier(providerCfg)
if key == "" {
continue
}
result[key] = providerCfg
}
if len(result) == 0 && len(cfg.APIKeys) > 0 {
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
if key := providerIdentifier(provider); key != "" {
result[key] = provider
}
}
}
return result
}
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
for i := range cfg.Access.Providers {
providerCfg := &cfg.Access.Providers[i]
if providerCfg.Type == "" {
continue
}
if key := providerIdentifier(providerCfg); key != "" {
entries = append(entries, providerCfg)
}
}
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
entries = append(entries, inline)
}
}
return entries
}
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
func identifierFromProvider(provider sdkaccess.Provider) string {
if provider == nil {
return ""
}
if name := strings.TrimSpace(provider.Name); name != "" {
return name
}
typ := strings.TrimSpace(provider.Type)
if typ == "" {
return ""
}
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
return sdkConfig.DefaultAccessProviderName
}
return typ
return strings.TrimSpace(provider.Identifier())
}
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
func providerInstanceEqual(a, b sdkaccess.Provider) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
if reflect.TypeOf(a) != reflect.TypeOf(b) {
return false
}
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
return false
valueA := reflect.ValueOf(a)
valueB := reflect.ValueOf(b)
if valueA.Kind() == reflect.Pointer && valueB.Kind() == reflect.Pointer {
return valueA.Pointer() == valueB.Pointer()
}
if !stringSetEqual(a.APIKeys, b.APIKeys) {
return false
}
if len(a.Config) != len(b.Config) {
return false
}
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
return false
}
return true
}
func stringSetEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
seen := make(map[string]int, len(a))
for _, val := range a {
seen[val]++
}
for _, val := range b {
count := seen[val]
if count == 0 {
return false
}
if count == 1 {
delete(seen, val)
} else {
seen[val] = count - 1
}
}
return len(seen) == 0
return reflect.DeepEqual(a, b)
}

View File

@@ -109,14 +109,13 @@ func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.c
func (h *Handler) PutAPIKeys(c *gin.Context) {
h.putStringList(c, func(v []string) {
h.cfg.APIKeys = append([]string(nil), v...)
h.cfg.Access.Providers = nil
}, nil)
}
func (h *Handler) PatchAPIKeys(c *gin.Context) {
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
h.patchStringList(c, &h.cfg.APIKeys, func() {})
}
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
h.deleteFromStringList(c, &h.cfg.APIKeys, func() {})
}
// gemini-api-key: []GeminiKey

View File

@@ -66,7 +66,7 @@ func (rw *ResponseRewriter) Flush() {
}
// 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
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility

View 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
}

View File

@@ -655,14 +655,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)
c.AbortWithStatus(http.StatusNotFound)
// Synchronously ensure management.html is available with a detached context.
// 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
}
log.WithError(err).Error("failed to stat management control panel asset")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.File(filePath)
@@ -952,10 +955,6 @@ func (s *Server) UpdateClients(cfg *config.Config) {
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 {
s.mgmt.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager)
@@ -1034,14 +1033,10 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
return
}
switch {
case errors.Is(err, sdkaccess.ErrNoCredentials):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"})
case errors.Is(err, sdkaccess.ErrInvalidCredential):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
default:
statusCode := err.HTTPStatusCode()
if statusCode >= http.StatusInternalServerError {
log.Errorf("authentication middleware error: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"})
}
c.AbortWithStatusJSON(statusCode, gin.H{"error": err.Message})
}
}

View File

@@ -589,9 +589,6 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.ErrorLogsMaxFiles = 10
}
// Sync request authentication providers with inline API keys for backwards compatibility.
syncInlineAccessProvider(&cfg)
// Sanitize Gemini API key configuration and migrate legacy entries.
cfg.SanitizeGeminiKeys()
@@ -825,18 +822,6 @@ func normalizeModelPrefix(prefix string) string {
return trimmed
}
func syncInlineAccessProvider(cfg *Config) {
if cfg == nil {
return
}
if len(cfg.APIKeys) == 0 {
if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
}
}
cfg.Access.Providers = nil
}
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
func looksLikeBcrypt(s string) bool {
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
@@ -924,7 +909,7 @@ func hashSecret(secret string) (string, error) {
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
persistCfg := sanitizeConfigForPersist(cfg)
persistCfg := cfg
// Load original YAML as a node tree to preserve comments and ordering.
data, err := os.ReadFile(configFile)
if err != nil {
@@ -992,16 +977,6 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
return err
}
func sanitizeConfigForPersist(cfg *Config) *Config {
if cfg == nil {
return nil
}
clone := *cfg
clone.SDKConfig = cfg.SDKConfig
clone.SDKConfig.Access = AccessConfig{}
return &clone
}
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
// while preserving comments and positions.
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
@@ -1098,8 +1073,13 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
// 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
// value is non-zero to avoid polluting the config with defaults.
func mergeMappingPreserve(dst, src *yaml.Node) {
// value is non-zero and not a known default to avoid polluting the config with defaults.
func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) {
var currentPath []string
if len(path) > 0 {
currentPath = path[0]
}
if dst == nil || src == nil {
return
}
@@ -1113,16 +1093,19 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
sk := src.Content[i]
sv := src.Content[i+1]
idx := findMapKeyIndex(dst, sk.Value)
childPath := appendPath(currentPath, sk.Value)
if idx >= 0 {
// Merge into existing value node (always update, even to zero values)
dv := dst.Content[idx+1]
mergeNodePreserve(dv, sv)
mergeNodePreserve(dv, sv, childPath)
} else {
// New key: only add if value is non-zero to avoid polluting config with defaults
if isZeroValueNode(sv) {
// New key: only add if value is non-zero and not a known default
candidate := deepCopyNode(sv)
pruneKnownDefaultsInNewNode(childPath, candidate)
if isKnownDefaultValue(childPath, candidate) {
continue
}
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
dst.Content = append(dst.Content, deepCopyNode(sk), candidate)
}
}
}
@@ -1130,7 +1113,12 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
// reusing destination nodes to keep comments and anchors. For sequences, it updates
// 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 {
return
}
@@ -1139,7 +1127,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
if dst.Kind != yaml.MappingNode {
copyNodeShallow(dst, src)
}
mergeMappingPreserve(dst, src)
mergeMappingPreserve(dst, src, currentPath)
case yaml.SequenceNode:
// 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 {
@@ -1162,7 +1150,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
dst.Content[i] = deepCopyNode(src.Content[i])
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 &&
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
pruneMissingMapKeys(dst.Content[i], src.Content[i])
@@ -1204,6 +1192,94 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int {
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
// 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.

View File

@@ -20,9 +20,6 @@ type SDKConfig struct {
// APIKeys is a list of keys for authenticating clients to this proxy server.
APIKeys []string `yaml:"api-keys" json:"api-keys"`
// Access holds request authentication provider configuration.
Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
// Streaming configures server-side streaming behavior (keep-alives and safe bootstrap retries).
Streaming StreamingConfig `yaml:"streaming" json:"streaming"`
@@ -42,65 +39,3 @@ type StreamingConfig struct {
// <= 0 disables bootstrap retries. Default is 0.
BootstrapRetries int `yaml:"bootstrap-retries,omitempty" json:"bootstrap-retries,omitempty"`
}
// AccessConfig groups request authentication providers.
type AccessConfig struct {
// Providers lists configured authentication providers.
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
}
// AccessProvider describes a request authentication provider entry.
type AccessProvider struct {
// Name is the instance identifier for the provider.
Name string `yaml:"name" json:"name"`
// Type selects the provider implementation registered via the SDK.
Type string `yaml:"type" json:"type"`
// SDK optionally names a third-party SDK module providing this provider.
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
// APIKeys lists inline keys for providers that require them.
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
// Config passes provider-specific options to the implementation.
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
}
const (
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
AccessProviderTypeConfigAPIKey = "config-api-key"
// DefaultAccessProviderName is applied when no provider name is supplied.
DefaultAccessProviderName = "config-inline"
)
// ConfigAPIKeyProvider returns the first inline API key provider if present.
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
if c == nil {
return nil
}
for i := range c.Access.Providers {
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
if c.Access.Providers[i].Name == "" {
c.Access.Providers[i].Name = DefaultAccessProviderName
}
return &c.Access.Providers[i]
}
}
return nil
}
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
// It returns nil when no keys are supplied.
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
if len(keys) == 0 {
return nil
}
provider := &AccessProvider{
Name: DefaultAccessProviderName,
Type: AccessProviderTypeConfigAPIKey,
APIKeys: append([]string(nil), keys...),
}
return provider
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/singleflight"
)
const (
@@ -28,6 +29,7 @@ const (
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
managementAssetName = "management.html"
httpUserAgent = "CLIProxyAPI-management-updater"
managementSyncMinInterval = 30 * time.Second
updateCheckInterval = 3 * time.Hour
)
@@ -37,11 +39,10 @@ const ManagementFileName = managementAssetName
var (
lastUpdateCheckMu sync.Mutex
lastUpdateCheckTime time.Time
currentConfigPtr atomic.Pointer[config.Config]
disableControlPanel atomic.Bool
schedulerOnce sync.Once
schedulerConfigPath atomic.Value
sfGroup singleflight.Group
)
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
@@ -50,16 +51,7 @@ func SetCurrentConfig(cfg *config.Config) {
currentConfigPtr.Store(nil)
return
}
prevDisabled := disableControlPanel.Load()
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.
@@ -92,7 +84,7 @@ func runAutoUpdater(ctx context.Context) {
log.Debug("management asset auto-updater skipped: config not yet available")
return
}
if disableControlPanel.Load() {
if cfg.RemoteManagement.DisableControlPanel {
log.Debug("management asset auto-updater skipped: control panel disabled")
return
}
@@ -181,103 +173,106 @@ func FilePath(configFilePath string) string {
}
// 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 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) {
// It coalesces concurrent sync attempts and returns whether the asset exists after the sync attempt.
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool {
if ctx == nil {
ctx = context.Background()
}
if disableControlPanel.Load() {
log.Debug("management asset sync skipped: control panel disabled by configuration")
return
}
staticDir = strings.TrimSpace(staticDir)
if staticDir == "" {
log.Debug("management asset sync skipped: empty static directory")
return
return false
}
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
lastUpdateCheckMu.Lock()
now := time.Now()
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
if timeSinceLastCheck < updateCheckInterval {
_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {
lastUpdateCheckMu.Lock()
now := time.Now()
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval {
lastUpdateCheckMu.Unlock()
log.Debugf(
"management asset sync skipped by throttle: last attempt %v ago (interval %v)",
timeSinceLastAttempt.Round(time.Second),
managementSyncMinInterval,
)
return nil, nil
}
lastUpdateCheckTime = now
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 {
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
return
}
releaseURL := resolveReleaseURL(panelRepository)
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
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")
}
return
}
log.WithError(err).Warn("failed to fetch latest management release information")
return
}
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
log.Debug("management asset is already up to date")
return
}
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
return nil, nil
}
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
releaseURL := resolveReleaseURL(panelRepository)
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")
}
return
localHash = ""
}
log.WithError(err).Warn("failed to download management asset")
return
}
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
}
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 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 {
log.WithError(err).Warn("failed to update management asset on disk")
return
}
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
log.Debug("management asset is already up to date")
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 {

View File

@@ -4,12 +4,16 @@ import (
"bufio"
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/google/uuid"
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/thinking"
@@ -453,6 +457,20 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) {
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+apiKey)
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 {
r.Header.Set("Accept", "text/event-stream")
} 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) {
if a == nil {
return "", ""

View File

@@ -70,7 +70,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
if role == "developer" {
role = "user"
}
message := `{"role":"","content":""}`
message := `{"role":"","content":[]}`
message, _ = sjson.Set(message, "role", role)
if content := item.Get("content"); content.Exists() && content.IsArray() {
@@ -84,20 +84,16 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
switch contentType {
case "input_text":
case "input_text", "output_text":
text := contentItem.Get("text").String()
if messageContent != "" {
messageContent += "\n" + text
} else {
messageContent = text
}
case "output_text":
text := contentItem.Get("text").String()
if messageContent != "" {
messageContent += "\n" + text
} else {
messageContent = text
}
contentPart := `{"type":"text","text":""}`
contentPart, _ = sjson.Set(contentPart, "text", text)
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
case "input_image":
imageURL := contentItem.Get("image_url").String()
contentPart := `{"type":"image_url","image_url":{"url":""}}`
contentPart, _ = sjson.Set(contentPart, "image_url.url", imageURL)
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
}
return true
})

View File

@@ -1,12 +1,90 @@
package access
import "errors"
var (
// ErrNoCredentials indicates no recognizable credentials were supplied.
ErrNoCredentials = errors.New("access: no credentials provided")
// ErrInvalidCredential signals that supplied credentials were rejected by a provider.
ErrInvalidCredential = errors.New("access: invalid credential")
// ErrNotHandled tells the manager to continue trying other providers.
ErrNotHandled = errors.New("access: not handled")
import (
"fmt"
"net/http"
"strings"
)
// AuthErrorCode classifies authentication failures.
type AuthErrorCode string
const (
AuthErrorCodeNoCredentials AuthErrorCode = "no_credentials"
AuthErrorCodeInvalidCredential AuthErrorCode = "invalid_credential"
AuthErrorCodeNotHandled AuthErrorCode = "not_handled"
AuthErrorCodeInternal AuthErrorCode = "internal_error"
)
// AuthError carries authentication failure details and HTTP status.
type AuthError struct {
Code AuthErrorCode
Message string
StatusCode int
Cause error
}
func (e *AuthError) Error() string {
if e == nil {
return ""
}
message := strings.TrimSpace(e.Message)
if message == "" {
message = "authentication error"
}
if e.Cause != nil {
return fmt.Sprintf("%s: %v", message, e.Cause)
}
return message
}
func (e *AuthError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// HTTPStatusCode returns a safe fallback for missing status codes.
func (e *AuthError) HTTPStatusCode() int {
if e == nil || e.StatusCode <= 0 {
return http.StatusInternalServerError
}
return e.StatusCode
}
func newAuthError(code AuthErrorCode, message string, statusCode int, cause error) *AuthError {
return &AuthError{
Code: code,
Message: message,
StatusCode: statusCode,
Cause: cause,
}
}
func NewNoCredentialsError() *AuthError {
return newAuthError(AuthErrorCodeNoCredentials, "Missing API key", http.StatusUnauthorized, nil)
}
func NewInvalidCredentialError() *AuthError {
return newAuthError(AuthErrorCodeInvalidCredential, "Invalid API key", http.StatusUnauthorized, nil)
}
func NewNotHandledError() *AuthError {
return newAuthError(AuthErrorCodeNotHandled, "authentication provider did not handle request", 0, nil)
}
func NewInternalAuthError(message string, cause error) *AuthError {
normalizedMessage := strings.TrimSpace(message)
if normalizedMessage == "" {
normalizedMessage = "Authentication service error"
}
return newAuthError(AuthErrorCodeInternal, normalizedMessage, http.StatusInternalServerError, cause)
}
func IsAuthErrorCode(authErr *AuthError, code AuthErrorCode) bool {
if authErr == nil {
return false
}
return authErr.Code == code
}

View File

@@ -2,7 +2,6 @@ package access
import (
"context"
"errors"
"net/http"
"sync"
)
@@ -43,7 +42,7 @@ func (m *Manager) Providers() []Provider {
}
// Authenticate evaluates providers until one succeeds.
func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, error) {
func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError) {
if m == nil {
return nil, nil
}
@@ -61,29 +60,29 @@ func (m *Manager) Authenticate(ctx context.Context, r *http.Request) (*Result, e
if provider == nil {
continue
}
res, err := provider.Authenticate(ctx, r)
if err == nil {
res, authErr := provider.Authenticate(ctx, r)
if authErr == nil {
return res, nil
}
if errors.Is(err, ErrNotHandled) {
if IsAuthErrorCode(authErr, AuthErrorCodeNotHandled) {
continue
}
if errors.Is(err, ErrNoCredentials) {
if IsAuthErrorCode(authErr, AuthErrorCodeNoCredentials) {
missing = true
continue
}
if errors.Is(err, ErrInvalidCredential) {
if IsAuthErrorCode(authErr, AuthErrorCodeInvalidCredential) {
invalid = true
continue
}
return nil, err
return nil, authErr
}
if invalid {
return nil, ErrInvalidCredential
return nil, NewInvalidCredentialError()
}
if missing {
return nil, ErrNoCredentials
return nil, NewNoCredentialsError()
}
return nil, ErrNoCredentials
return nil, NewNoCredentialsError()
}

View File

@@ -2,17 +2,15 @@ package access
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
// Provider validates credentials for incoming requests.
type Provider interface {
Identifier() string
Authenticate(ctx context.Context, r *http.Request) (*Result, error)
Authenticate(ctx context.Context, r *http.Request) (*Result, *AuthError)
}
// Result conveys authentication outcome.
@@ -22,66 +20,64 @@ type Result struct {
Metadata map[string]string
}
// ProviderFactory builds a provider from configuration data.
type ProviderFactory func(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error)
var (
registryMu sync.RWMutex
registry = make(map[string]ProviderFactory)
registry = make(map[string]Provider)
order []string
)
// RegisterProvider registers a provider factory for a given type identifier.
func RegisterProvider(typ string, factory ProviderFactory) {
if typ == "" || factory == nil {
// RegisterProvider registers a pre-built provider instance for a given type identifier.
func RegisterProvider(typ string, provider Provider) {
normalizedType := strings.TrimSpace(typ)
if normalizedType == "" || provider == nil {
return
}
registryMu.Lock()
registry[typ] = factory
if _, exists := registry[normalizedType]; !exists {
order = append(order, normalizedType)
}
registry[normalizedType] = provider
registryMu.Unlock()
}
func BuildProvider(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error) {
if cfg == nil {
return nil, fmt.Errorf("access: nil provider config")
// UnregisterProvider removes a provider by type identifier.
func UnregisterProvider(typ string) {
normalizedType := strings.TrimSpace(typ)
if normalizedType == "" {
return
}
registryMu.RLock()
factory, ok := registry[cfg.Type]
registryMu.RUnlock()
if !ok {
return nil, fmt.Errorf("access: provider type %q is not registered", cfg.Type)
registryMu.Lock()
if _, exists := registry[normalizedType]; !exists {
registryMu.Unlock()
return
}
provider, err := factory(cfg, root)
if err != nil {
return nil, fmt.Errorf("access: failed to build provider %q: %w", cfg.Name, err)
}
return provider, nil
}
// BuildProviders constructs providers declared in configuration.
func BuildProviders(root *config.SDKConfig) ([]Provider, error) {
if root == nil {
return nil, nil
}
providers := make([]Provider, 0, len(root.Access.Providers))
for i := range root.Access.Providers {
providerCfg := &root.Access.Providers[i]
if providerCfg.Type == "" {
delete(registry, normalizedType)
for index := range order {
if order[index] != normalizedType {
continue
}
provider, err := BuildProvider(providerCfg, root)
if err != nil {
return nil, err
order = append(order[:index], order[index+1:]...)
break
}
registryMu.Unlock()
}
// RegisteredProviders returns the global provider instances in registration order.
func RegisteredProviders() []Provider {
registryMu.RLock()
if len(order) == 0 {
registryMu.RUnlock()
return nil
}
providers := make([]Provider, 0, len(order))
for _, providerType := range order {
provider, exists := registry[providerType]
if !exists || provider == nil {
continue
}
providers = append(providers, provider)
}
if len(providers) == 0 {
if inline := config.MakeInlineAPIKeyProvider(root.APIKeys); inline != nil {
provider, err := BuildProvider(inline, root)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
}
return providers, nil
registryMu.RUnlock()
return providers
}

47
sdk/access/types.go Normal file
View 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
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"strings"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
@@ -186,11 +187,8 @@ func (b *Builder) Build() (*Service, error) {
accessManager = sdkaccess.NewManager()
}
providers, err := sdkaccess.BuildProviders(&b.cfg.SDKConfig)
if err != nil {
return nil, err
}
accessManager.SetProviders(providers)
configaccess.Register(&b.cfg.SDKConfig)
accessManager.SetProviders(sdkaccess.RegisteredProviders())
coreManager := b.coreManager
if coreManager == nil {

View File

@@ -7,8 +7,6 @@ package config
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
type SDKConfig = internalconfig.SDKConfig
type AccessConfig = internalconfig.AccessConfig
type AccessProvider = internalconfig.AccessProvider
type Config = internalconfig.Config
@@ -34,15 +32,9 @@ type OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel
type TLS = internalconfig.TLSConfig
const (
AccessProviderTypeConfigAPIKey = internalconfig.AccessProviderTypeConfigAPIKey
DefaultAccessProviderName = internalconfig.DefaultAccessProviderName
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
)
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
return internalconfig.MakeInlineAPIKeyProvider(keys)
}
func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {