feat: support disabling providers
This commit is contained in:
parent
f8c0384e93
commit
d585bf35c1
46
README.md
46
README.md
@ -101,7 +101,7 @@ olpx alias list
|
|||||||
注意:
|
注意:
|
||||||
|
|
||||||
- target 的顺序就是失败切换顺序
|
- target 的顺序就是失败切换顺序
|
||||||
- enabled alias 必须至少有一个 enabled target
|
- enabled alias 必须至少有一个可路由 target
|
||||||
- `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
- `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias
|
||||||
|
|
||||||
### 3. 先做一次静态检查
|
### 3. 先做一次静态检查
|
||||||
@ -116,7 +116,7 @@ olpx doctor
|
|||||||
|
|
||||||
- 本地 `olpx` 配置能不能正常加载
|
- 本地 `olpx` 配置能不能正常加载
|
||||||
- alias 是否引用了不存在的 provider
|
- alias 是否引用了不存在的 provider
|
||||||
- enabled alias 是否至少有一个 enabled target
|
- enabled alias 是否至少有一个可路由 target
|
||||||
- 本地代理监听地址是否合理
|
- 本地代理监听地址是否合理
|
||||||
- 默认会同步到哪个 OpenCode 配置文件
|
- 默认会同步到哪个 OpenCode 配置文件
|
||||||
|
|
||||||
@ -126,7 +126,7 @@ olpx doctor
|
|||||||
olpx opencode sync
|
olpx opencode sync
|
||||||
```
|
```
|
||||||
|
|
||||||
这个命令会做一件事:把当前 alias 列表同步进 OpenCode 的 `provider.olpx.models`。
|
这个命令会做一件事:把当前可路由的 alias 列表同步进 OpenCode 的 `provider.olpx.models`。
|
||||||
|
|
||||||
默认行为:
|
默认行为:
|
||||||
|
|
||||||
@ -262,6 +262,18 @@ olpx provider add --id <id> --base-url <url-with-/v1> --api-key <key>
|
|||||||
olpx provider list
|
olpx provider list
|
||||||
```
|
```
|
||||||
|
|
||||||
|
禁用 provider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
olpx provider disable <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
重新启用 provider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
olpx provider enable <id>
|
||||||
|
```
|
||||||
|
|
||||||
删除 provider:
|
删除 provider:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -365,7 +377,8 @@ olpx --config /path/to/config.json doctor
|
|||||||
"id": "codex",
|
"id": "codex",
|
||||||
"name": "Codex",
|
"name": "Codex",
|
||||||
"base_url": "https://api-vip.codex-for.me/v1",
|
"base_url": "https://api-vip.codex-for.me/v1",
|
||||||
"api_key": "sk-yyy"
|
"api_key": "sk-yyy",
|
||||||
|
"disabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": [
|
"aliases": [
|
||||||
@ -406,7 +419,7 @@ olpx --config /path/to/config.json doctor
|
|||||||
|
|
||||||
- alias 不存在
|
- alias 不存在
|
||||||
- alias 被禁用
|
- alias 被禁用
|
||||||
- alias 没有 enabled target
|
- alias 没有可用 target
|
||||||
- 上游返回 `400`
|
- 上游返回 `400`
|
||||||
- 上游返回 `401`
|
- 上游返回 `401`
|
||||||
- 上游返回 `403`
|
- 上游返回 `403`
|
||||||
@ -445,19 +458,32 @@ olpx --config /path/to/config.json doctor
|
|||||||
|
|
||||||
1. 你是否执行过 `olpx opencode sync`
|
1. 你是否执行过 `olpx opencode sync`
|
||||||
2. 你的 alias 是否是 enabled 状态
|
2. 你的 alias 是否是 enabled 状态
|
||||||
3. alias 是否至少绑定了一个 enabled target
|
3. alias 是否至少绑定了一个可路由 target
|
||||||
4. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件
|
4. alias 绑定的 provider 是否都被禁用了
|
||||||
5. 执行一次 `olpx doctor`,看输出里的 `opencode config target`
|
5. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件
|
||||||
|
6. 执行一次 `olpx doctor`,看输出里的 `opencode config target`
|
||||||
|
|
||||||
### 为什么 `olpx doctor` 报 alias 没有 enabled target?
|
### 为什么 `olpx doctor` 报 alias 没有可用 target?
|
||||||
|
|
||||||
因为当前实现要求:只要 alias 是 enabled,就必须至少有一个 enabled target。
|
因为当前实现要求:只要 alias 是 enabled,就必须至少有一个可路由的 target。
|
||||||
|
|
||||||
|
可路由的意思是同时满足:
|
||||||
|
|
||||||
|
- target 自己是 enabled
|
||||||
|
- target 引用的 provider 存在且没有被禁用
|
||||||
|
|
||||||
你可以:
|
你可以:
|
||||||
|
|
||||||
- 给它绑定 target
|
- 给它绑定 target
|
||||||
|
- 重新启用被禁用的 provider
|
||||||
- 或者把这个 alias 改成 disabled 后再保存
|
- 或者把这个 alias 改成 disabled 后再保存
|
||||||
|
|
||||||
|
### 为什么要禁用 provider,而不是把 alias target 也改成 disabled?
|
||||||
|
|
||||||
|
因为 provider 可能被多个 alias 复用。
|
||||||
|
|
||||||
|
`olpx provider disable` 只会让路由层在 failover 时自动跳过这个 provider,不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。
|
||||||
|
|
||||||
### 删除 provider 后为什么还有报错?
|
### 删除 provider 后为什么还有报错?
|
||||||
|
|
||||||
因为 alias 里的 target 还是旧引用。需要继续执行:
|
因为 alias 里的 target 还是旧引用。需要继续执行:
|
||||||
|
|||||||
31
README_EN.md
31
README_EN.md
@ -7,6 +7,7 @@ stable model alias** routed to **multiple upstream providers** with
|
|||||||
- Expose one custom provider `olpx` to OpenCode.
|
- Expose one custom provider `olpx` to OpenCode.
|
||||||
- Configure logical aliases (`olpx/gpt-5.4`, etc.).
|
- Configure logical aliases (`olpx/gpt-5.4`, etc.).
|
||||||
- Each alias has an ordered list of upstream `provider/model` targets.
|
- Each alias has an ordered list of upstream `provider/model` targets.
|
||||||
|
- Providers can be disabled without mutating alias target state.
|
||||||
- When the primary upstream returns `5xx`/`429`/connect error *before* any
|
- When the primary upstream returns `5xx`/`429`/connect error *before* any
|
||||||
stream bytes are flushed, `olpx` transparently retries the next target.
|
stream bytes are flushed, `olpx` transparently retries the next target.
|
||||||
- Once a stream has started, the upstream is locked for the rest of that
|
- Once a stream has started, the upstream is locked for the rest of that
|
||||||
@ -35,6 +36,9 @@ olpx alias bind --alias gpt-5.4 --provider codex --model GPT-5.4
|
|||||||
# 3. push alias exposure into OpenCode global config
|
# 3. push alias exposure into OpenCode global config
|
||||||
olpx opencode sync
|
olpx opencode sync
|
||||||
|
|
||||||
|
# optional: temporarily disable one provider without editing alias targets
|
||||||
|
olpx provider disable su8
|
||||||
|
|
||||||
# 4. run the proxy
|
# 4. run the proxy
|
||||||
olpx serve
|
olpx serve
|
||||||
```
|
```
|
||||||
@ -63,11 +67,33 @@ olpx doctor
|
|||||||
|
|
||||||
Runs structural checks only — never issues real upstream requests.
|
Runs structural checks only — never issues real upstream requests.
|
||||||
|
|
||||||
|
Enabled aliases must have at least one **routable** target. A target is
|
||||||
|
considered routable only when:
|
||||||
|
|
||||||
|
- the alias itself is enabled
|
||||||
|
- the target itself is enabled
|
||||||
|
- the referenced provider exists
|
||||||
|
- the referenced provider is not disabled
|
||||||
|
|
||||||
|
`olpx opencode sync` and `/v1/models` use the same routable-alias view, so
|
||||||
|
OpenCode does not see aliases that the proxy would immediately reject.
|
||||||
|
|
||||||
|
### Provider state
|
||||||
|
|
||||||
|
```bash
|
||||||
|
olpx provider disable <id>
|
||||||
|
olpx provider enable <id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Disabling a provider only removes it from routing/failover consideration. It
|
||||||
|
does **not** rewrite alias target `enabled` flags in config, which avoids odd
|
||||||
|
interactions when the same provider is shared across multiple aliases.
|
||||||
|
|
||||||
## CLI reference
|
## CLI reference
|
||||||
|
|
||||||
- `olpx serve` — run the proxy
|
- `olpx serve` — run the proxy
|
||||||
- `olpx doctor` — validate config
|
- `olpx doctor` — validate config
|
||||||
- `olpx provider {add,list,remove,import-opencode}`
|
- `olpx provider {add,list,enable,disable,remove,import-opencode}`
|
||||||
- `olpx alias {add,list,bind,unbind,remove}`
|
- `olpx alias {add,list,bind,unbind,remove}`
|
||||||
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
|
- `olpx opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]`
|
||||||
|
|
||||||
@ -86,6 +112,7 @@ Every proxied response includes:
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing,
|
Out of MVP: Anthropic native, multi-protocol routing, dashboard, billing,
|
||||||
latency-based routing, `/v1/models` discovery, full OpenCode config takeover.
|
latency-based routing, full `/v1/models` provider discovery, full OpenCode
|
||||||
|
config takeover.
|
||||||
See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md`
|
See `.trellis/tasks/archive/2026-04/04-17-04-17-ops-mvp-design-review/prd.md`
|
||||||
for the authoritative design notes.
|
for the authoritative design notes.
|
||||||
|
|||||||
@ -85,7 +85,15 @@ func newAliasListCmd() *cobra.Command {
|
|||||||
if !t.Enabled {
|
if !t.Enabled {
|
||||||
mark = " "
|
mark = " "
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), " [%s] %d. %s/%s\n", mark, i+1, t.Provider, t.Model)
|
note := ""
|
||||||
|
provider := cfg.FindProvider(t.Provider)
|
||||||
|
switch {
|
||||||
|
case provider == nil:
|
||||||
|
note = " (missing provider)"
|
||||||
|
case !provider.IsEnabled():
|
||||||
|
note = " (provider disabled)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " [%s] %d. %s/%s%s\n", mark, i+1, t.Provider, t.Model, note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -17,11 +17,12 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
|
|||||||
t.Fatalf("loadCfg: %v", err)
|
t.Fatalf("loadCfg: %v", err)
|
||||||
}
|
}
|
||||||
cfg.UpsertProvider(config.Provider{
|
cfg.UpsertProvider(config.Provider{
|
||||||
ID: "p1",
|
ID: "p1",
|
||||||
Name: "Old",
|
Name: "Old",
|
||||||
BaseURL: "https://old.example.com/v1",
|
BaseURL: "https://old.example.com/v1",
|
||||||
APIKey: "sk-old",
|
APIKey: "sk-old",
|
||||||
Headers: map[string]string{"X-Test": "1"},
|
Headers: map[string]string{"X-Test": "1"},
|
||||||
|
Disabled: true,
|
||||||
})
|
})
|
||||||
if err := cfg.Save(); err != nil {
|
if err := cfg.Save(); err != nil {
|
||||||
t.Fatalf("save config: %v", err)
|
t.Fatalf("save config: %v", err)
|
||||||
@ -53,6 +54,9 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
|
|||||||
if p.BaseURL != "https://new.example.com/v1" {
|
if p.BaseURL != "https://new.example.com/v1" {
|
||||||
t.Fatalf("BaseURL = %q, want updated URL", p.BaseURL)
|
t.Fatalf("BaseURL = %q, want updated URL", p.BaseURL)
|
||||||
}
|
}
|
||||||
|
if !p.Disabled {
|
||||||
|
t.Fatal("Disabled = false, want true to preserve provider state")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
|
func TestProviderAddRejectsInvalidBaseURL(t *testing.T) {
|
||||||
@ -116,3 +120,48 @@ func TestAliasAddPreservesExistingFields(t *testing.T) {
|
|||||||
t.Fatalf("Targets = %#v, want preserved target", a.Targets)
|
t.Fatalf("Targets = %#v, want preserved target", a.Targets)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProviderEnableDisableCommands(t *testing.T) {
|
||||||
|
t.Setenv("OLPX_CONFIG", filepath.Join(t.TempDir(), "olpx.json"))
|
||||||
|
configPath = ""
|
||||||
|
|
||||||
|
cfg, err := loadCfg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCfg: %v", err)
|
||||||
|
}
|
||||||
|
cfg.UpsertProvider(config.Provider{
|
||||||
|
ID: "p1",
|
||||||
|
BaseURL: "https://example.com/v1",
|
||||||
|
})
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
t.Fatalf("save config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
disableCmd := newProviderDisableCmd()
|
||||||
|
disableCmd.SetArgs([]string{"p1"})
|
||||||
|
if err := disableCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("execute provider disable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = loadCfg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload after disable: %v", err)
|
||||||
|
}
|
||||||
|
if provider := cfg.FindProvider("p1"); provider == nil || !provider.Disabled {
|
||||||
|
t.Fatalf("provider after disable = %#v, want disabled=true", provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
enableCmd := newProviderEnableCmd()
|
||||||
|
enableCmd.SetArgs([]string{"p1"})
|
||||||
|
if err := enableCmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("execute provider enable: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err = loadCfg()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload after enable: %v", err)
|
||||||
|
}
|
||||||
|
if provider := cfg.FindProvider("p1"); provider == nil || provider.Disabled {
|
||||||
|
t.Fatalf("provider after enable = %#v, want disabled=false", provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -24,13 +24,7 @@ func newDoctorCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
|
issues = append(issues, fmt.Errorf("load opencode config target: %w", err))
|
||||||
} else {
|
} else {
|
||||||
aliasNames := []string{}
|
aliasNames := cfg.AvailableAliasNames()
|
||||||
for _, a := range cfg.Aliases {
|
|
||||||
if !a.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
aliasNames = append(aliasNames, a.Alias)
|
|
||||||
}
|
|
||||||
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
||||||
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||||
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil {
|
||||||
|
|||||||
@ -51,13 +51,7 @@ or creating opencode.jsonc if none exists. It does NOT touch the top-level
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
aliasNames := []string{}
|
aliasNames := cfg.AvailableAliasNames()
|
||||||
for _, a := range cfg.Aliases {
|
|
||||||
if !a.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
aliasNames = append(aliasNames, a.Alias)
|
|
||||||
}
|
|
||||||
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port)
|
||||||
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames)
|
||||||
if setModel != "" {
|
if setModel != "" {
|
||||||
|
|||||||
@ -16,13 +16,21 @@ func newProviderCmd() *cobra.Command {
|
|||||||
Use: "provider",
|
Use: "provider",
|
||||||
Short: "Manage upstream providers",
|
Short: "Manage upstream providers",
|
||||||
}
|
}
|
||||||
c.AddCommand(newProviderAddCmd(), newProviderListCmd(), newProviderRemoveCmd(), newProviderImportCmd())
|
c.AddCommand(
|
||||||
|
newProviderAddCmd(),
|
||||||
|
newProviderListCmd(),
|
||||||
|
newProviderEnableCmd(),
|
||||||
|
newProviderDisableCmd(),
|
||||||
|
newProviderRemoveCmd(),
|
||||||
|
newProviderImportCmd(),
|
||||||
|
)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProviderAddCmd() *cobra.Command {
|
func newProviderAddCmd() *cobra.Command {
|
||||||
var id, name, baseURL, apiKey string
|
var id, name, baseURL, apiKey string
|
||||||
var headers []string
|
var headers []string
|
||||||
|
var disabled bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add",
|
Use: "add",
|
||||||
Short: "Add or update an upstream provider",
|
Short: "Add or update an upstream provider",
|
||||||
@ -46,11 +54,12 @@ func newProviderAddCmd() *cobra.Command {
|
|||||||
hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
p := config.Provider{
|
p := config.Provider{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: name,
|
Name: name,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
APIKey: apiKey,
|
APIKey: apiKey,
|
||||||
Headers: hdrs,
|
Headers: hdrs,
|
||||||
|
Disabled: disabled,
|
||||||
}
|
}
|
||||||
if existing := cfg.FindProvider(id); existing != nil {
|
if existing := cfg.FindProvider(id); existing != nil {
|
||||||
if p.Name == "" {
|
if p.Name == "" {
|
||||||
@ -62,12 +71,19 @@ func newProviderAddCmd() *cobra.Command {
|
|||||||
if len(headers) == 0 && len(existing.Headers) > 0 {
|
if len(headers) == 0 && len(existing.Headers) > 0 {
|
||||||
p.Headers = cloneHeaders(existing.Headers)
|
p.Headers = cloneHeaders(existing.Headers)
|
||||||
}
|
}
|
||||||
|
if !disabled {
|
||||||
|
p.Disabled = existing.Disabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cfg.UpsertProvider(p)
|
cfg.UpsertProvider(p)
|
||||||
if err := cfg.Save(); err != nil {
|
if err := cfg.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q → %s\n", id, baseURL)
|
state := "enabled"
|
||||||
|
if p.Disabled {
|
||||||
|
state = "disabled"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "saved provider %q [%s] → %s\n", id, state, baseURL)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -76,6 +92,7 @@ func newProviderAddCmd() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)")
|
cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)")
|
||||||
cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key")
|
cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key")
|
||||||
cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)")
|
cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)")
|
||||||
|
cmd.Flags().BoolVar(&disabled, "disabled", false, "save provider in disabled state")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,13 +127,55 @@ func newProviderListCmd() *cobra.Command {
|
|||||||
if p.APIKey != "" {
|
if p.APIKey != "" {
|
||||||
key = maskKey(p.APIKey)
|
key = maskKey(p.APIKey)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "%-20s %s apiKey=%s\n", p.ID, p.BaseURL, key)
|
state := "enabled"
|
||||||
|
if !p.IsEnabled() {
|
||||||
|
state = "disabled"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%-20s [%s] %s apiKey=%s\n", p.ID, state, p.BaseURL, key)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newProviderEnableCmd() *cobra.Command {
|
||||||
|
return newProviderStateCmd("enable", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProviderDisableCmd() *cobra.Command {
|
||||||
|
return newProviderStateCmd("disable", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProviderStateCmd(use string, disabled bool) *cobra.Command {
|
||||||
|
action := "enabled"
|
||||||
|
if disabled {
|
||||||
|
action = "disabled"
|
||||||
|
}
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: use + " <id>",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Short: strings.Title(action[:len(action)-1]) + " a provider without changing alias target state",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg, err := loadCfg()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := cfg.FindProvider(args[0])
|
||||||
|
if existing == nil {
|
||||||
|
return fmt.Errorf("provider %q not found", args[0])
|
||||||
|
}
|
||||||
|
updated := *existing
|
||||||
|
updated.Disabled = disabled
|
||||||
|
cfg.UpsertProvider(updated)
|
||||||
|
if err := cfg.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s provider %q\n", action, args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newProviderRemoveCmd() *cobra.Command {
|
func newProviderRemoveCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "remove <id>",
|
Use: "remove <id>",
|
||||||
@ -175,10 +234,11 @@ func newProviderImportCmd() *cobra.Command {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cfg.UpsertProvider(config.Provider{
|
cfg.UpsertProvider(config.Provider{
|
||||||
ID: ip.ID,
|
ID: ip.ID,
|
||||||
Name: ip.Name,
|
Name: ip.Name,
|
||||||
BaseURL: ip.BaseURL,
|
BaseURL: ip.BaseURL,
|
||||||
APIKey: ip.APIKey,
|
APIKey: ip.APIKey,
|
||||||
|
Disabled: false,
|
||||||
})
|
})
|
||||||
imported++
|
imported++
|
||||||
fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.Models, ","))
|
fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.Models, ","))
|
||||||
|
|||||||
@ -29,11 +29,12 @@ type Alias struct {
|
|||||||
|
|
||||||
// Provider is one upstream OpenAI-compatible endpoint.
|
// Provider is one upstream OpenAI-compatible endpoint.
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
Headers map[string]string `json:"headers,omitempty"`
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Disabled bool `json:"disabled,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server holds proxy listen settings.
|
// Server holds proxy listen settings.
|
||||||
@ -53,6 +54,11 @@ type Config struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEnabled reports whether the provider can be used for routing.
|
||||||
|
func (p Provider) IsEnabled() bool {
|
||||||
|
return !p.Disabled
|
||||||
|
}
|
||||||
|
|
||||||
// ValidateProviderBaseURL checks the MVP requirement that upstream base URLs
|
// ValidateProviderBaseURL checks the MVP requirement that upstream base URLs
|
||||||
// point at an OpenAI-compatible /v1 root.
|
// point at an OpenAI-compatible /v1 root.
|
||||||
func ValidateProviderBaseURL(baseURL string) error {
|
func ValidateProviderBaseURL(baseURL string) error {
|
||||||
@ -166,6 +172,10 @@ func (c *Config) Save() error {
|
|||||||
func (c *Config) FindProvider(id string) *Provider {
|
func (c *Config) FindProvider(id string) *Provider {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
return c.findProviderLocked(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) findProviderLocked(id string) *Provider {
|
||||||
for i := range c.Providers {
|
for i := range c.Providers {
|
||||||
if c.Providers[i].ID == id {
|
if c.Providers[i].ID == id {
|
||||||
return &c.Providers[i]
|
return &c.Providers[i]
|
||||||
@ -283,6 +293,47 @@ func (c *Config) RemoveTarget(alias, provider, model string) error {
|
|||||||
return fmt.Errorf("alias %q not found", alias)
|
return fmt.Errorf("alias %q not found", alias)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AvailableTargets returns alias targets that are individually enabled and point
|
||||||
|
// at providers that still exist and are enabled.
|
||||||
|
func (c *Config) AvailableTargets(alias Alias) []Target {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.availableTargetsLocked(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) availableTargetsLocked(alias Alias) []Target {
|
||||||
|
targets := make([]Target, 0, len(alias.Targets))
|
||||||
|
for _, t := range alias.Targets {
|
||||||
|
if !t.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider := c.findProviderLocked(t.Provider)
|
||||||
|
if provider == nil || !provider.IsEnabled() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, t)
|
||||||
|
}
|
||||||
|
return targets
|
||||||
|
}
|
||||||
|
|
||||||
|
// AvailableAliasNames returns alias names that are enabled and still have at
|
||||||
|
// least one routable target after provider availability is applied.
|
||||||
|
func (c *Config) AvailableAliasNames() []string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
names := make([]string, 0, len(c.Aliases))
|
||||||
|
for _, a := range c.Aliases {
|
||||||
|
if !a.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(c.availableTargetsLocked(a)) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, a.Alias)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
// Validate returns a non-nil error slice for every structural issue found.
|
// Validate returns a non-nil error slice for every structural issue found.
|
||||||
func (c *Config) Validate() []error {
|
func (c *Config) Validate() []error {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
@ -325,12 +376,10 @@ func (c *Config) Validate() []error {
|
|||||||
if !ids[t.Provider] {
|
if !ids[t.Provider] {
|
||||||
errs = append(errs, fmt.Errorf("alias %q references unknown provider %q", a.Alias, t.Provider))
|
errs = append(errs, fmt.Errorf("alias %q references unknown provider %q", a.Alias, t.Provider))
|
||||||
}
|
}
|
||||||
if t.Enabled {
|
|
||||||
enabled++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
enabled = len(c.availableTargetsLocked(a))
|
||||||
if a.Enabled && enabled == 0 {
|
if a.Enabled && enabled == 0 {
|
||||||
errs = append(errs, fmt.Errorf("alias %q has no enabled targets", a.Alias))
|
errs = append(errs, fmt.Errorf("alias %q has no available targets", a.Alias))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
if c.Server.Port <= 0 || c.Server.Port > 65535 {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateProviderBaseURL(t *testing.T) {
|
func TestValidateProviderBaseURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@ -36,3 +38,75 @@ func TestValidateProviderBaseURL(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAvailableTargetsSkipsDisabledAndMissingProviders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: []Provider{
|
||||||
|
{ID: "p1", BaseURL: "https://p1.example.com/v1"},
|
||||||
|
{ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alias := Alias{
|
||||||
|
Alias: "gpt-5.4",
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []Target{
|
||||||
|
{Provider: "p1", Model: "up-1", Enabled: true},
|
||||||
|
{Provider: "p2", Model: "up-2", Enabled: true},
|
||||||
|
{Provider: "missing", Model: "up-3", Enabled: true},
|
||||||
|
{Provider: "p1", Model: "up-4", Enabled: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := cfg.AvailableTargets(alias)
|
||||||
|
if len(targets) != 1 {
|
||||||
|
t.Fatalf("available targets = %#v, want exactly one", targets)
|
||||||
|
}
|
||||||
|
if targets[0].Provider != "p1" || targets[0].Model != "up-1" {
|
||||||
|
t.Fatalf("available target = %#v, want p1/up-1", targets[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvailableAliasNamesOnlyReturnsRoutableAliases(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Providers: []Provider{
|
||||||
|
{ID: "p1", BaseURL: "https://p1.example.com/v1"},
|
||||||
|
{ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true},
|
||||||
|
},
|
||||||
|
Aliases: []Alias{
|
||||||
|
{Alias: "ok", Enabled: true, Targets: []Target{{Provider: "p1", Model: "up-1", Enabled: true}}},
|
||||||
|
{Alias: "provider-disabled", Enabled: true, Targets: []Target{{Provider: "p2", Model: "up-2", Enabled: true}}},
|
||||||
|
{Alias: "alias-disabled", Enabled: false, Targets: []Target{{Provider: "p1", Model: "up-3", Enabled: true}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
names := cfg.AvailableAliasNames()
|
||||||
|
if len(names) != 1 || names[0] != "ok" {
|
||||||
|
t.Fatalf("available alias names = %#v, want [ok]", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateReportsAliasWithoutAvailableTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Server: Server{Host: "127.0.0.1", Port: 9982, APIKey: "olpx-local"},
|
||||||
|
Providers: []Provider{{ID: "p1", BaseURL: "https://p1.example.com/v1", Disabled: true}},
|
||||||
|
Aliases: []Alias{{
|
||||||
|
Alias: "gpt-5.4",
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []Target{{Provider: "p1", Model: "up-1", Enabled: true}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
errs := cfg.Validate()
|
||||||
|
if len(errs) != 1 {
|
||||||
|
t.Fatalf("Validate() errors = %v, want 1 error", errs)
|
||||||
|
}
|
||||||
|
if got := errs[0].Error(); got != `alias "gpt-5.4" has no available targets` {
|
||||||
|
t.Fatalf("Validate() error = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -105,12 +105,9 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := []map[string]any{}
|
data := []map[string]any{}
|
||||||
for _, a := range s.cfg.Aliases {
|
for _, aliasName := range s.cfg.AvailableAliasNames() {
|
||||||
if !a.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data = append(data, map[string]any{
|
data = append(data, map[string]any{
|
||||||
"id": a.Alias,
|
"id": aliasName,
|
||||||
"object": "model",
|
"object": "model",
|
||||||
"owned_by": "olpx",
|
"owned_by": "olpx",
|
||||||
})
|
})
|
||||||
@ -177,23 +174,18 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
|
writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var targets []config.Target
|
targets := s.cfg.AvailableTargets(*alias)
|
||||||
for _, t := range alias.Targets {
|
|
||||||
if t.Enabled {
|
|
||||||
targets = append(targets, t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
s.logger.Printf("req=%d alias=%q has no enabled targets", reqID, aliasName)
|
s.logger.Printf("req=%d alias=%q has no available targets", reqID, aliasName)
|
||||||
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no enabled targets", aliasName))
|
writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no available targets", aliasName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
failoverCount := 0
|
failoverCount := 0
|
||||||
for attempt, t := range targets {
|
for attempt, t := range targets {
|
||||||
p := s.cfg.FindProvider(t.Provider)
|
p := s.cfg.FindProvider(t.Provider)
|
||||||
if p == nil {
|
if p == nil || !p.IsEnabled() {
|
||||||
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q missing, skipping", reqID, aliasName, attempt+1, t.Provider)
|
s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider)
|
||||||
failoverCount++
|
failoverCount++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,6 +157,109 @@ func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleResponsesSkipsDisabledProviders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
calledDisabled := false
|
||||||
|
disabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calledDisabled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer disabled.Close()
|
||||||
|
|
||||||
|
var seenModel string
|
||||||
|
enabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
seenModel, _ = payload["model"].(string)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write([]byte("data: ok\n\n"))
|
||||||
|
}))
|
||||||
|
defer enabled.Close()
|
||||||
|
|
||||||
|
srv := New(&config.Config{
|
||||||
|
Server: config.Server{APIKey: "olpx-local"},
|
||||||
|
Providers: []config.Provider{
|
||||||
|
{ID: "p1", BaseURL: disabled.URL + "/v1", Disabled: true},
|
||||||
|
{ID: "p2", BaseURL: enabled.URL + "/v1"},
|
||||||
|
},
|
||||||
|
Aliases: []config.Alias{{
|
||||||
|
Alias: "gpt-5.4",
|
||||||
|
Enabled: true,
|
||||||
|
Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}, {Provider: "p2", Model: "up-2", Enabled: true}},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","stream":true}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.handleResponses(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if calledDisabled {
|
||||||
|
t.Fatal("disabled provider should be skipped before any upstream call")
|
||||||
|
}
|
||||||
|
if seenModel != "up-2" {
|
||||||
|
t.Fatalf("enabled upstream model = %q, want up-2", seenModel)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("X-OLPX-Attempt"); got != "1" {
|
||||||
|
t.Fatalf("X-OLPX-Attempt = %q, want 1", got)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("X-OLPX-Failover-Count"); got != "0" {
|
||||||
|
t.Fatalf("X-OLPX-Failover-Count = %q, want 0", got)
|
||||||
|
}
|
||||||
|
if got := rr.Header().Get("X-OLPX-Provider"); got != "p2" {
|
||||||
|
t.Fatalf("X-OLPX-Provider = %q, want p2", got)
|
||||||
|
}
|
||||||
|
if body := rr.Body.String(); body != "data: ok\n\n" {
|
||||||
|
t.Fatalf("body = %q, want SSE payload", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModelsSkipsAliasesWithoutAvailableTargets(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := New(&config.Config{
|
||||||
|
Server: config.Server{APIKey: "olpx-local"},
|
||||||
|
Providers: []config.Provider{
|
||||||
|
{ID: "p1", BaseURL: "https://p1.example.com/v1"},
|
||||||
|
{ID: "p2", BaseURL: "https://p2.example.com/v1", Disabled: true},
|
||||||
|
},
|
||||||
|
Aliases: []config.Alias{
|
||||||
|
{Alias: "ok", Enabled: true, Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}}},
|
||||||
|
{Alias: "no-route", Enabled: true, Targets: []config.Target{{Provider: "p2", Model: "up-2", Enabled: true}}},
|
||||||
|
{Alias: "alias-disabled", Enabled: false, Targets: []config.Target{{Provider: "p1", Model: "up-3", Enabled: true}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer olpx-local")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
srv.handleModels(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if body := rr.Body.String(); !strings.Contains(body, `"id":"ok"`) {
|
||||||
|
t.Fatalf("models body = %q, want alias ok", body)
|
||||||
|
}
|
||||||
|
if body := rr.Body.String(); strings.Contains(body, `"id":"no-route"`) {
|
||||||
|
t.Fatalf("models body = %q, disabled-provider alias should be hidden", body)
|
||||||
|
}
|
||||||
|
if body := rr.Body.String(); strings.Contains(body, `"id":"alias-disabled"`) {
|
||||||
|
t.Fatalf("models body = %q, disabled alias should be hidden", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadFirstChunk(t *testing.T) {
|
func TestReadFirstChunk(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user