From d585bf35c112e9455a2e5df2368003b46738b507 Mon Sep 17 00:00:00 2001 From: apale7 <1092377056@qq.com> Date: Fri, 17 Apr 2026 14:01:28 +0800 Subject: [PATCH] feat: support disabling providers --- README.md | 46 +++++++++++---- README_EN.md | 31 +++++++++- internal/cli/alias.go | 10 +++- internal/cli/cli_test.go | 59 +++++++++++++++++-- internal/cli/doctor.go | 8 +-- internal/cli/opencode.go | 8 +-- internal/cli/provider.go | 84 +++++++++++++++++++++++---- internal/config/config.go | 67 ++++++++++++++++++--- internal/config/config_test.go | 76 +++++++++++++++++++++++- internal/proxy/server.go | 22 +++---- internal/proxy/server_test.go | 103 +++++++++++++++++++++++++++++++++ 11 files changed, 445 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 9ae7f20..c087cde 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ olpx alias list 注意: - target 的顺序就是失败切换顺序 -- enabled alias 必须至少有一个 enabled target +- enabled alias 必须至少有一个可路由 target - `olpx alias bind` 在 alias 不存在时会自动创建一个 enabled alias ### 3. 先做一次静态检查 @@ -116,7 +116,7 @@ olpx doctor - 本地 `olpx` 配置能不能正常加载 - alias 是否引用了不存在的 provider -- enabled alias 是否至少有一个 enabled target +- enabled alias 是否至少有一个可路由 target - 本地代理监听地址是否合理 - 默认会同步到哪个 OpenCode 配置文件 @@ -126,7 +126,7 @@ olpx doctor olpx opencode sync ``` -这个命令会做一件事:把当前 alias 列表同步进 OpenCode 的 `provider.olpx.models`。 +这个命令会做一件事:把当前可路由的 alias 列表同步进 OpenCode 的 `provider.olpx.models`。 默认行为: @@ -262,6 +262,18 @@ olpx provider add --id --base-url --api-key olpx provider list ``` +禁用 provider: + +```bash +olpx provider disable +``` + +重新启用 provider: + +```bash +olpx provider enable +``` + 删除 provider: ```bash @@ -365,7 +377,8 @@ olpx --config /path/to/config.json doctor "id": "codex", "name": "Codex", "base_url": "https://api-vip.codex-for.me/v1", - "api_key": "sk-yyy" + "api_key": "sk-yyy", + "disabled": true } ], "aliases": [ @@ -406,7 +419,7 @@ olpx --config /path/to/config.json doctor - alias 不存在 - alias 被禁用 -- alias 没有 enabled target +- alias 没有可用 target - 上游返回 `400` - 上游返回 `401` - 上游返回 `403` @@ -445,19 +458,32 @@ olpx --config /path/to/config.json doctor 1. 你是否执行过 `olpx opencode sync` 2. 你的 alias 是否是 enabled 状态 -3. alias 是否至少绑定了一个 enabled target -4. OpenCode 当前实际使用的配置文件,是否就是 `olpx opencode sync` 写入的那个文件 -5. 执行一次 `olpx doctor`,看输出里的 `opencode config target` +3. alias 是否至少绑定了一个可路由 target +4. alias 绑定的 provider 是否都被禁用了 +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 +- 重新启用被禁用的 provider - 或者把这个 alias 改成 disabled 后再保存 +### 为什么要禁用 provider,而不是把 alias target 也改成 disabled? + +因为 provider 可能被多个 alias 复用。 + +`olpx provider disable` 只会让路由层在 failover 时自动跳过这个 provider,不会改写 alias 里的 target 状态,这样重新启用 provider 时不会和 alias 上原有的启用关系打架。 + ### 删除 provider 后为什么还有报错? 因为 alias 里的 target 还是旧引用。需要继续执行: diff --git a/README_EN.md b/README_EN.md index 7228de7..74fca93 100644 --- a/README_EN.md +++ b/README_EN.md @@ -7,6 +7,7 @@ stable model alias** routed to **multiple upstream providers** with - Expose one custom provider `olpx` to OpenCode. - Configure logical aliases (`olpx/gpt-5.4`, etc.). - 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 stream bytes are flushed, `olpx` transparently retries the next target. - 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 olpx opencode sync +# optional: temporarily disable one provider without editing alias targets +olpx provider disable su8 + # 4. run the proxy olpx serve ``` @@ -63,11 +67,33 @@ olpx doctor 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 +olpx provider enable +``` + +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 - `olpx serve` — run the proxy - `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 opencode sync [--target FILE] [--set-model ALIAS] [--set-small-model ALIAS] [--dry-run]` @@ -86,6 +112,7 @@ Every proxied response includes: ## Scope 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` for the authoritative design notes. diff --git a/internal/cli/alias.go b/internal/cli/alias.go index e5073ef..c60b9e4 100644 --- a/internal/cli/alias.go +++ b/internal/cli/alias.go @@ -85,7 +85,15 @@ func newAliasListCmd() *cobra.Command { if !t.Enabled { 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 diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 7d17399..0193013 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -17,11 +17,12 @@ func TestProviderAddPreservesExistingFields(t *testing.T) { t.Fatalf("loadCfg: %v", err) } cfg.UpsertProvider(config.Provider{ - ID: "p1", - Name: "Old", - BaseURL: "https://old.example.com/v1", - APIKey: "sk-old", - Headers: map[string]string{"X-Test": "1"}, + ID: "p1", + Name: "Old", + BaseURL: "https://old.example.com/v1", + APIKey: "sk-old", + Headers: map[string]string{"X-Test": "1"}, + Disabled: true, }) if err := cfg.Save(); err != nil { t.Fatalf("save config: %v", err) @@ -53,6 +54,9 @@ func TestProviderAddPreservesExistingFields(t *testing.T) { if p.BaseURL != "https://new.example.com/v1" { 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) { @@ -116,3 +120,48 @@ func TestAliasAddPreservesExistingFields(t *testing.T) { 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) + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 1699a61..75735e1 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -24,13 +24,7 @@ func newDoctorCmd() *cobra.Command { if err != nil { issues = append(issues, fmt.Errorf("load opencode config target: %w", err)) } else { - aliasNames := []string{} - for _, a := range cfg.Aliases { - if !a.Enabled { - continue - } - aliasNames = append(aliasNames, a.Alias) - } + aliasNames := cfg.AvailableAliasNames() baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) if err := opencode.ValidateOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil { diff --git a/internal/cli/opencode.go b/internal/cli/opencode.go index 2f5ef89..c282dfb 100644 --- a/internal/cli/opencode.go +++ b/internal/cli/opencode.go @@ -51,13 +51,7 @@ or creating opencode.jsonc if none exists. It does NOT touch the top-level if err != nil { return err } - aliasNames := []string{} - for _, a := range cfg.Aliases { - if !a.Enabled { - continue - } - aliasNames = append(aliasNames, a.Alias) - } + aliasNames := cfg.AvailableAliasNames() baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) changed := opencode.EnsureOLPXProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) if setModel != "" { diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 5d2f196..2d5932a 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -16,13 +16,21 @@ func newProviderCmd() *cobra.Command { Use: "provider", Short: "Manage upstream providers", } - c.AddCommand(newProviderAddCmd(), newProviderListCmd(), newProviderRemoveCmd(), newProviderImportCmd()) + c.AddCommand( + newProviderAddCmd(), + newProviderListCmd(), + newProviderEnableCmd(), + newProviderDisableCmd(), + newProviderRemoveCmd(), + newProviderImportCmd(), + ) return c } func newProviderAddCmd() *cobra.Command { var id, name, baseURL, apiKey string var headers []string + var disabled bool cmd := &cobra.Command{ Use: "add", Short: "Add or update an upstream provider", @@ -46,11 +54,12 @@ func newProviderAddCmd() *cobra.Command { hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v) } p := config.Provider{ - ID: id, - Name: name, - BaseURL: baseURL, - APIKey: apiKey, - Headers: hdrs, + ID: id, + Name: name, + BaseURL: baseURL, + APIKey: apiKey, + Headers: hdrs, + Disabled: disabled, } if existing := cfg.FindProvider(id); existing != nil { if p.Name == "" { @@ -62,12 +71,19 @@ func newProviderAddCmd() *cobra.Command { if len(headers) == 0 && len(existing.Headers) > 0 { p.Headers = cloneHeaders(existing.Headers) } + if !disabled { + p.Disabled = existing.Disabled + } } cfg.UpsertProvider(p) if err := cfg.Save(); err != nil { 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 }, } @@ -76,6 +92,7 @@ func newProviderAddCmd() *cobra.Command { cmd.Flags().StringVar(&baseURL, "base-url", "", "OpenAI-compatible base URL, including /v1 (required)") cmd.Flags().StringVar(&apiKey, "api-key", "", "upstream API key") cmd.Flags().StringArrayVar(&headers, "header", nil, "extra header KEY=VALUE (repeatable)") + cmd.Flags().BoolVar(&disabled, "disabled", false, "save provider in disabled state") return cmd } @@ -110,13 +127,55 @@ func newProviderListCmd() *cobra.Command { if 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 + " ", + 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 { return &cobra.Command{ Use: "remove ", @@ -175,10 +234,11 @@ func newProviderImportCmd() *cobra.Command { continue } cfg.UpsertProvider(config.Provider{ - ID: ip.ID, - Name: ip.Name, - BaseURL: ip.BaseURL, - APIKey: ip.APIKey, + ID: ip.ID, + Name: ip.Name, + BaseURL: ip.BaseURL, + APIKey: ip.APIKey, + Disabled: false, }) imported++ fmt.Fprintf(cmd.OutOrStdout(), "import %q → %s (models: %s)\n", ip.ID, ip.BaseURL, strings.Join(ip.Models, ",")) diff --git a/internal/config/config.go b/internal/config/config.go index 884bbf0..c024b8a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,11 +29,12 @@ type Alias struct { // Provider is one upstream OpenAI-compatible endpoint. type Provider struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Headers map[string]string `json:"headers,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Headers map[string]string `json:"headers,omitempty"` + Disabled bool `json:"disabled,omitempty"` } // Server holds proxy listen settings. @@ -53,6 +54,11 @@ type Config struct { 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 // point at an OpenAI-compatible /v1 root. func ValidateProviderBaseURL(baseURL string) error { @@ -166,6 +172,10 @@ func (c *Config) Save() error { func (c *Config) FindProvider(id string) *Provider { c.mu.RLock() defer c.mu.RUnlock() + return c.findProviderLocked(id) +} + +func (c *Config) findProviderLocked(id string) *Provider { for i := range c.Providers { if c.Providers[i].ID == id { 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) } +// 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. func (c *Config) Validate() []error { c.mu.RLock() @@ -325,12 +376,10 @@ func (c *Config) Validate() []error { if !ids[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 { - 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7f56e35..344a878 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,8 @@ package config -import "testing" +import ( + "testing" +) func TestValidateProviderBaseURL(t *testing.T) { 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) + } +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 05b0a69..9e30286 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -105,12 +105,9 @@ func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) { return } data := []map[string]any{} - for _, a := range s.cfg.Aliases { - if !a.Enabled { - continue - } + for _, aliasName := range s.cfg.AvailableAliasNames() { data = append(data, map[string]any{ - "id": a.Alias, + "id": aliasName, "object": "model", "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)) return } - var targets []config.Target - for _, t := range alias.Targets { - if t.Enabled { - targets = append(targets, t) - } - } + targets := s.cfg.AvailableTargets(*alias) if len(targets) == 0 { - s.logger.Printf("req=%d alias=%q has no enabled targets", reqID, aliasName) - writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no enabled targets", 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 available targets", aliasName)) return } failoverCount := 0 for attempt, t := range targets { p := s.cfg.FindProvider(t.Provider) - if p == nil { - s.logger.Printf("req=%d alias=%s attempt=%d target provider %q missing, skipping", reqID, aliasName, attempt+1, t.Provider) + if p == nil || !p.IsEnabled() { + s.logger.Printf("req=%d alias=%s attempt=%d target provider %q unavailable, skipping", reqID, aliasName, attempt+1, t.Provider) failoverCount++ continue } diff --git a/internal/proxy/server_test.go b/internal/proxy/server_test.go index c9638a7..a9f7cdf 100644 --- a/internal/proxy/server_test.go +++ b/internal/proxy/server_test.go @@ -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) { t.Parallel()