feat: support disabling providers

This commit is contained in:
apale7 2026-04-17 14:01:28 +08:00
parent f8c0384e93
commit d585bf35c1
11 changed files with 445 additions and 69 deletions

View File

@ -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 还是旧引用。需要继续执行:

View File

@ -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.

View File

@ -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

View File

@ -22,6 +22,7 @@ func TestProviderAddPreservesExistingFields(t *testing.T) {
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)
}
}

View File

@ -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 {

View File

@ -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 != "" {

View File

@ -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",
@ -51,6 +59,7 @@ func newProviderAddCmd() *cobra.Command {
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,8 +127,50 @@ 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
},
}
}
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 return nil
}, },
} }
@ -179,6 +238,7 @@ func newProviderImportCmd() *cobra.Command {
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, ","))

View File

@ -34,6 +34,7 @@ type Provider struct {
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 {

View File

@ -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)
}
}

View File

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

View File

@ -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()