From 37c6ee064011db1b9736379465604b1926222d64 Mon Sep 17 00:00:00 2001 From: apale7 <1092377056@qq.com> Date: Fri, 17 Apr 2026 10:42:56 +0800 Subject: [PATCH] fix: harden ops sync and proxy behavior --- README.md | 3 + README_EN.md | 4 + internal/cli/alias.go | 7 + internal/cli/cli_test.go | 118 +++++++++ internal/cli/doctor.go | 21 +- internal/cli/provider.go | 30 ++- internal/config/config.go | 18 ++ internal/config/config_test.go | 38 +++ internal/opencode/opencode.go | 398 ++++++++++++++++++++++++++++- internal/opencode/opencode_test.go | 294 +++++++++++++++++++++ internal/proxy/server.go | 110 +++++++- internal/proxy/server_test.go | 237 +++++++++++++++++ 12 files changed, 1256 insertions(+), 22 deletions(-) create mode 100644 internal/cli/cli_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/opencode/opencode_test.go create mode 100644 internal/proxy/server_test.go diff --git a/README.md b/README.md index df70935..a457206 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ ops opencode sync - 优先复用全局 OpenCode 配置文件:`opencode.jsonc` > `opencode.json` > `config.json` - 如果都不存在,就创建 `~/.config/opencode/opencode.jsonc` +- 默认目标明确只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR` - 只更新 `provider.ops` - 不会修改顶层 `model` - 不会修改顶层 `small_model` @@ -231,6 +232,8 @@ ops provider import-opencode --from ./examples/opencode.jsonc 注意: - 这不是完整迁移工具 +- 默认导入源也只看全局用户配置目录,不跟随 `OPENCODE_CONFIG_DIR` +- 如果你要导入别的 OpenCode 配置文件,请显式传 `--from` - 当前只导入 provider 的基本连接信息 - 如果你的旧配置依赖额外自定义 header,需要导入后自己用 `ops provider add --header ...` 补齐 - `ops` 自己不会被反向导入 diff --git a/README_EN.md b/README_EN.md index 902c826..c25ef4a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -48,6 +48,10 @@ ops provider import-opencode # reads global OpenCode config ops provider import-opencode --from ./examples/opencode.jsonc ``` +The default import/sync target is the global user config only. It does not +follow `OPENCODE_CONFIG_DIR`; use `--from` or `--target` when you want a +different file. + Only `@ai-sdk/openai` custom providers with a `baseURL` and `apiKey` are imported. Everything else is out of MVP scope. diff --git a/internal/cli/alias.go b/internal/cli/alias.go index 3ab449f..f1deef5 100644 --- a/internal/cli/alias.go +++ b/internal/cli/alias.go @@ -36,6 +36,13 @@ func newAliasAddCmd() *cobra.Command { existing := cfg.FindAlias(name) a := config.Alias{Alias: name, DisplayName: display, Enabled: !disabled} if existing != nil { + if display == "" { + a.DisplayName = existing.DisplayName + } + a.Enabled = existing.Enabled + if disabled { + a.Enabled = false + } a.Targets = existing.Targets } cfg.UpsertAlias(a) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..5033131 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,118 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/anomalyco/opencode-provider-switch/internal/config" +) + +func TestProviderAddPreservesExistingFields(t *testing.T) { + t.Setenv("OPS_CONFIG", filepath.Join(t.TempDir(), "ops.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + 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"}, + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://new.example.com/v1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute provider add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + p := cfg.FindProvider("p1") + if p == nil { + t.Fatal("provider p1 not found") + } + if p.Name != "Old" { + t.Fatalf("Name = %q, want Old", p.Name) + } + if p.APIKey != "sk-old" { + t.Fatalf("APIKey = %q, want sk-old", p.APIKey) + } + if p.Headers["X-Test"] != "1" { + t.Fatalf("Headers = %#v, want preserved header", p.Headers) + } + if p.BaseURL != "https://new.example.com/v1" { + t.Fatalf("BaseURL = %q, want updated URL", p.BaseURL) + } +} + +func TestProviderAddRejectsInvalidBaseURL(t *testing.T) { + configFile := filepath.Join(t.TempDir(), "ops.json") + t.Setenv("OPS_CONFIG", configFile) + configPath = "" + + cmd := newProviderAddCmd() + cmd.SetArgs([]string{"--id", "p1", "--base-url", "https://example.com/api"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected invalid --base-url error") + } + if got := err.Error(); got != "invalid --base-url: base_url must end with /v1" { + t.Fatalf("error = %q", got) + } + if _, statErr := os.Stat(configFile); !os.IsNotExist(statErr) { + t.Fatalf("expected no config file write, stat err = %v", statErr) + } +} + +func TestAliasAddPreservesExistingFields(t *testing.T) { + t.Setenv("OPS_CONFIG", filepath.Join(t.TempDir(), "ops.json")) + configPath = "" + + cfg, err := loadCfg() + if err != nil { + t.Fatalf("loadCfg: %v", err) + } + cfg.UpsertAlias(config.Alias{ + Alias: "gpt-5.4", + DisplayName: "Old Name", + Enabled: true, + Targets: []config.Target{{Provider: "p1", Model: "up-1", Enabled: true}}, + }) + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + cmd := newAliasAddCmd() + cmd.SetArgs([]string{"--name", "gpt-5.4"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute alias add: %v", err) + } + + cfg, err = loadCfg() + if err != nil { + t.Fatalf("reload config: %v", err) + } + a := cfg.FindAlias("gpt-5.4") + if a == nil { + t.Fatal("alias gpt-5.4 not found") + } + if a.DisplayName != "Old Name" { + t.Fatalf("DisplayName = %q, want Old Name", a.DisplayName) + } + if !a.Enabled { + t.Fatal("Enabled = false, want true") + } + if len(a.Targets) != 1 || a.Targets[0].Provider != "p1" || a.Targets[0].Model != "up-1" { + t.Fatalf("Targets = %#v, want preserved target", a.Targets) + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9c736e5..5dd496a 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -18,6 +18,25 @@ func newDoctorCmd() *cobra.Command { return err } issues := cfg.Validate() + + path, existed := opencode.ResolveGlobalConfigPath() + raw, err := opencode.Load(path) + 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) + } + baseURL := fmt.Sprintf("http://%s:%d/v1", cfg.Server.Host, cfg.Server.Port) + opencode.EnsureOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames) + if err := opencode.ValidateOpsProvider(raw, baseURL, cfg.Server.APIKey, aliasNames); err != nil { + issues = append(issues, fmt.Errorf("opencode provider.ops invalid: %w", err)) + } + } ok := len(issues) == 0 if ok { fmt.Fprintf(cmd.OutOrStdout(), "✓ config loaded: %s\n", cfg.Path()) @@ -31,12 +50,12 @@ func newDoctorCmd() *cobra.Command { fmt.Fprintf(cmd.OutOrStdout(), " aliases: %d\n", len(cfg.Aliases)) // Preview resolved opencode config target - path, existed := opencode.ResolveGlobalConfigPath() marker := "(will be created)" if existed { marker = "(exists)" } fmt.Fprintf(cmd.OutOrStdout(), " opencode config target: %s %s\n", path, marker) + fmt.Fprintf(cmd.OutOrStdout(), " provider.ops preview: valid=%v\n", ok) fmt.Fprintf(cmd.OutOrStdout(), " proxy bind: %s:%d\n", cfg.Server.Host, cfg.Server.Port) if !ok { diff --git a/internal/cli/provider.go b/internal/cli/provider.go index 71adc62..5d2f196 100644 --- a/internal/cli/provider.go +++ b/internal/cli/provider.go @@ -30,6 +30,9 @@ func newProviderAddCmd() *cobra.Command { if id == "" || baseURL == "" { return fmt.Errorf("--id and --base-url are required") } + if err := config.ValidateProviderBaseURL(baseURL); err != nil { + return fmt.Errorf("invalid --base-url: %w", err) + } cfg, err := loadCfg() if err != nil { return err @@ -42,13 +45,25 @@ func newProviderAddCmd() *cobra.Command { } hdrs[strings.TrimSpace(k)] = strings.TrimSpace(v) } - cfg.UpsertProvider(config.Provider{ + p := config.Provider{ ID: id, Name: name, BaseURL: baseURL, APIKey: apiKey, Headers: hdrs, - }) + } + if existing := cfg.FindProvider(id); existing != nil { + if p.Name == "" { + p.Name = existing.Name + } + if p.APIKey == "" { + p.APIKey = existing.APIKey + } + if len(headers) == 0 && len(existing.Headers) > 0 { + p.Headers = cloneHeaders(existing.Headers) + } + } + cfg.UpsertProvider(p) if err := cfg.Save(); err != nil { return err } @@ -64,6 +79,17 @@ func newProviderAddCmd() *cobra.Command { return cmd } +func cloneHeaders(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + func newProviderListCmd() *cobra.Command { return &cobra.Command{ Use: "list", diff --git a/internal/config/config.go b/internal/config/config.go index 4db04b1..0717143 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strings" "sync" ) @@ -52,6 +53,19 @@ type Config struct { mu sync.RWMutex } +// ValidateProviderBaseURL checks the MVP requirement that upstream base URLs +// point at an OpenAI-compatible /v1 root. +func ValidateProviderBaseURL(baseURL string) error { + trimmed := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if trimmed == "" { + return fmt.Errorf("missing base_url") + } + if !strings.HasSuffix(trimmed, "/v1") { + return fmt.Errorf("base_url must end with /v1") + } + return nil +} + // Default returns an empty config with sane defaults. func Default() *Config { return &Config{ @@ -286,6 +300,10 @@ func (c *Config) Validate() []error { ids[p.ID] = true if p.BaseURL == "" { errs = append(errs, fmt.Errorf("provider %q missing base_url", p.ID)) + continue + } + if err := ValidateProviderBaseURL(p.BaseURL); err != nil { + errs = append(errs, fmt.Errorf("provider %q %s", p.ID, err)) } } seen := map[string]bool{} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..7f56e35 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,38 @@ +package config + +import "testing" + +func TestValidateProviderBaseURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + {name: "valid exact", input: "https://example.com/v1"}, + {name: "valid trailing slash", input: "https://example.com/v1/"}, + {name: "valid trimmed", input: " https://example.com/v1/ "}, + {name: "missing", input: "", wantErr: "missing base_url"}, + {name: "missing v1", input: "https://example.com/api", wantErr: "base_url must end with /v1"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := ValidateProviderBaseURL(tt.input) + if tt.wantErr == "" && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error %q, got nil", tt.wantErr) + } + if err.Error() != tt.wantErr { + t.Fatalf("expected error %q, got %q", tt.wantErr, err.Error()) + } + } + }) + } +} diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go index f5844b0..83686a6 100644 --- a/internal/opencode/opencode.go +++ b/internal/opencode/opencode.go @@ -4,11 +4,13 @@ package opencode import ( + "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" + "sort" "github.com/tidwall/jsonc" ) @@ -17,10 +19,9 @@ import ( var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"} // GlobalConfigDir returns the default user-global OpenCode config directory. +// MVP intentionally ignores OPENCODE_CONFIG_DIR for default sync scope; callers +// that want another file must pass --target explicitly. func GlobalConfigDir() string { - if dir := os.Getenv("OPENCODE_CONFIG_DIR"); dir != "" { - return dir - } if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "opencode") } @@ -70,17 +71,18 @@ func Load(path string) (Raw, error) { return out, nil } -// Save writes Raw back to path. Parent dirs are created. Writes are atomic. +// Save writes provider.ops back to path. Existing files are normalized to plain +// JSON and only the provider.ops subtree is patched so unrelated key order stays +// stable. New files are still written from the full Raw object. Writes are +// atomic. func Save(path string, raw Raw) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("mkdir: %w", err) } - // ensure $schema first; tidwall/jsonc indent not needed — plain JSON is fine. - data, err := json.MarshalIndent(raw, "", " ") + data, err := renderSaveData(path, raw) if err != nil { - return fmt.Errorf("marshal: %w", err) + return err } - data = append(data, '\n') tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0o600); err != nil { return fmt.Errorf("write tmp: %w", err) @@ -88,6 +90,325 @@ func Save(path string, raw Raw) error { return os.Rename(tmp, path) } +func renderSaveData(path string, raw Raw) ([]byte, error) { + original, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return marshalRaw(raw) + } + if len(bytes.TrimSpace(original)) == 0 { + return marshalRaw(raw) + } + patched, err := patchProviderOpsDocument(original, raw) + if err != nil { + return nil, fmt.Errorf("patch %s: %w", path, err) + } + if !json.Valid(patched) { + return nil, fmt.Errorf("patch %s: produced invalid json", path) + } + patched = append(bytes.TrimRight(patched, "\n"), '\n') + return patched, nil +} + +func marshalRaw(raw Raw) ([]byte, error) { + data, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + data = append(data, '\n') + return data, nil +} + +func patchProviderOpsDocument(original []byte, raw Raw) ([]byte, error) { + opsRaw, ok := opsProviderValue(raw) + if !ok { + return marshalRaw(raw) + } + normalized := bytes.TrimSpace(jsonc.ToJSON(original)) + if len(normalized) == 0 { + return marshalRaw(raw) + } + if !json.Valid(normalized) { + return nil, fmt.Errorf("source config is not valid json/jsonc") + } + rootStart := skipWhitespace(normalized, 0) + root, end, err := parseObjectSpan(normalized, rootStart) + if err != nil { + return nil, err + } + if skipWhitespace(normalized, end) != len(normalized) { + return nil, fmt.Errorf("source config must be a single top-level object") + } + provider := root.findMember("provider") + if provider == nil { + return insertObjectMember(normalized, root, "provider", map[string]any{"ops": opsRaw}) + } + if normalized[provider.valueStart] != '{' { + return nil, fmt.Errorf("top-level provider must be an object") + } + providerObj, _, err := parseObjectSpan(normalized, provider.valueStart) + if err != nil { + return nil, err + } + ops := providerObj.findMember("ops") + if ops == nil { + return insertObjectMember(normalized, providerObj, "ops", opsRaw) + } + return replaceObjectMember(normalized, *ops, opsRaw) +} + +func opsProviderValue(raw Raw) (map[string]any, bool) { + providerRaw, _ := raw["provider"].(map[string]any) + if providerRaw == nil { + return nil, false + } + opsRaw, _ := providerRaw["ops"].(map[string]any) + if opsRaw == nil { + return nil, false + } + return opsRaw, true +} + +type objectSpan struct { + start int + end int + members []objectMember +} + +type objectMember struct { + key string + start int + valueStart int + valueEnd int +} + +func (o objectSpan) findMember(key string) *objectMember { + for i := range o.members { + if o.members[i].key == key { + return &o.members[i] + } + } + return nil +} + +func replaceObjectMember(data []byte, member objectMember, value any) ([]byte, error) { + memberIndent := lineIndent(data, member.start) + replacement, err := formatObjectMember(member.key, value, memberIndent) + if err != nil { + return nil, err + } + out := append([]byte{}, data[:member.start]...) + out = append(out, replacement...) + out = append(out, data[member.valueEnd:]...) + return out, nil +} + +func insertObjectMember(data []byte, obj objectSpan, key string, value any) ([]byte, error) { + objIndent := lineIndent(data, obj.start) + childIndent := objIndent + " " + if len(obj.members) > 0 { + childIndent = lineIndent(data, obj.members[0].start) + } + memberText, err := formatObjectMember(key, value, childIndent) + if err != nil { + return nil, err + } + if len(obj.members) == 0 { + out := append([]byte{}, data[:obj.start+1]...) + out = append(out, '\n') + out = append(out, childIndent...) + out = append(out, memberText...) + out = append(out, '\n') + out = append(out, objIndent...) + out = append(out, data[obj.end-1:]...) + return out, nil + } + insertAt := obj.members[len(obj.members)-1].valueEnd + out := append([]byte{}, data[:insertAt]...) + out = append(out, []byte(",\n")...) + out = append(out, childIndent...) + out = append(out, memberText...) + out = append(out, '\n') + out = append(out, objIndent...) + out = append(out, data[obj.end-1:]...) + return out, nil +} + +func formatObjectMember(key string, value any, indent string) ([]byte, error) { + valueJSON, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal member %q: %w", key, err) + } + lines := bytes.Split(valueJSON, []byte("\n")) + quotedKey, err := json.Marshal(key) + if err != nil { + return nil, fmt.Errorf("marshal key %q: %w", key, err) + } + out := append([]byte{}, quotedKey...) + out = append(out, []byte(": ")...) + out = append(out, lines[0]...) + for _, line := range lines[1:] { + out = append(out, '\n') + out = append(out, indent...) + out = append(out, line...) + } + return out, nil +} + +func parseObjectSpan(data []byte, start int) (objectSpan, int, error) { + if start >= len(data) || data[start] != '{' { + return objectSpan{}, 0, fmt.Errorf("expected object at byte %d", start) + } + obj := objectSpan{start: start} + i := skipWhitespace(data, start+1) + if i >= len(data) { + return objectSpan{}, 0, fmt.Errorf("unterminated object") + } + if data[i] == '}' { + obj.end = i + 1 + return obj, obj.end, nil + } + for { + memberStart := skipWhitespace(data, i) + key, next, err := parseJSONString(data, memberStart) + if err != nil { + return objectSpan{}, 0, err + } + i = skipWhitespace(data, next) + if i >= len(data) || data[i] != ':' { + return objectSpan{}, 0, fmt.Errorf("expected ':' after key %q", key) + } + valueStart := skipWhitespace(data, i+1) + valueEnd, err := parseValueEnd(data, valueStart) + if err != nil { + return objectSpan{}, 0, err + } + obj.members = append(obj.members, objectMember{key: key, start: memberStart, valueStart: valueStart, valueEnd: valueEnd}) + i = skipWhitespace(data, valueEnd) + if i >= len(data) { + return objectSpan{}, 0, fmt.Errorf("unterminated object") + } + if data[i] == '}' { + obj.end = i + 1 + return obj, obj.end, nil + } + if data[i] != ',' { + return objectSpan{}, 0, fmt.Errorf("expected ',' or '}' in object") + } + i++ + } +} + +func parseValueEnd(data []byte, start int) (int, error) { + if start >= len(data) { + return 0, fmt.Errorf("missing value at byte %d", start) + } + switch data[start] { + case '{': + _, end, err := parseObjectSpan(data, start) + return end, err + case '[': + return parseArrayEnd(data, start) + case '"': + _, end, err := parseJSONString(data, start) + return end, err + default: + end := start + for end < len(data) { + switch data[end] { + case ' ', '\n', '\r', '\t', ',', '}', ']': + return end, nil + default: + end++ + } + } + return end, nil + } +} + +func parseArrayEnd(data []byte, start int) (int, error) { + if start >= len(data) || data[start] != '[' { + return 0, fmt.Errorf("expected array at byte %d", start) + } + i := skipWhitespace(data, start+1) + if i >= len(data) { + return 0, fmt.Errorf("unterminated array") + } + if data[i] == ']' { + return i + 1, nil + } + for { + valueStart := skipWhitespace(data, i) + valueEnd, err := parseValueEnd(data, valueStart) + if err != nil { + return 0, err + } + i = skipWhitespace(data, valueEnd) + if i >= len(data) { + return 0, fmt.Errorf("unterminated array") + } + if data[i] == ']' { + return i + 1, nil + } + if data[i] != ',' { + return 0, fmt.Errorf("expected ',' or ']' in array") + } + i++ + } +} + +func parseJSONString(data []byte, start int) (string, int, error) { + if start >= len(data) || data[start] != '"' { + return "", 0, fmt.Errorf("expected string at byte %d", start) + } + i := start + 1 + for i < len(data) { + if data[i] == '\\' { + i += 2 + continue + } + if data[i] == '"' { + var out string + if err := json.Unmarshal(data[start:i+1], &out); err != nil { + return "", 0, fmt.Errorf("parse string at byte %d: %w", start, err) + } + return out, i + 1, nil + } + i++ + } + return "", 0, fmt.Errorf("unterminated string at byte %d", start) +} + +func skipWhitespace(data []byte, i int) int { + for i < len(data) { + switch data[i] { + case ' ', '\n', '\r', '\t': + i++ + default: + return i + } + } + return i +} + +func lineIndent(data []byte, pos int) string { + lineStart := pos + for lineStart > 0 && data[lineStart-1] != '\n' { + lineStart-- + } + lineEnd := lineStart + for lineEnd < len(data) { + if data[lineEnd] == ' ' || data[lineEnd] == '\t' { + lineEnd++ + continue + } + break + } + return string(data[lineStart:lineEnd]) +} + // EnsureOpsProvider updates (or creates) the provider.ops entry with the given // local base URL, local api key and alias set. Existing keys on provider.ops // are preserved unless they conflict with the sync intent. Returns true if the @@ -128,6 +449,9 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool { if setIfDiff(opts, "apiKey", apiKey) { changed = true } + if setIfDiff(opts, "setCacheKey", true) { + changed = true + } // Build models map from alias list. Preserve any existing per-model extras // if the alias key matches; drop aliases removed locally. existingModels, _ := opsRaw["models"].(map[string]any) @@ -158,6 +482,64 @@ func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool { return changed } +// ValidateOpsProvider checks that provider.ops matches the MVP sync contract. +func ValidateOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) error { + provRaw, _ := raw["provider"].(map[string]any) + if provRaw == nil { + return fmt.Errorf("missing provider object") + } + opsRaw, _ := provRaw["ops"].(map[string]any) + if opsRaw == nil { + return fmt.Errorf("missing provider.ops") + } + if npm, _ := opsRaw["npm"].(string); npm != "@ai-sdk/openai" { + return fmt.Errorf("provider.ops.npm must be @ai-sdk/openai") + } + if name, _ := opsRaw["name"].(string); name != "OPS" { + return fmt.Errorf("provider.ops.name must be OPS") + } + opts, _ := opsRaw["options"].(map[string]any) + if opts == nil { + return fmt.Errorf("provider.ops.options missing") + } + if got, _ := opts["baseURL"].(string); got != baseURL { + return fmt.Errorf("provider.ops.options.baseURL mismatch") + } + if got, _ := opts["apiKey"].(string); got != apiKey { + return fmt.Errorf("provider.ops.options.apiKey mismatch") + } + if got, ok := opts["setCacheKey"].(bool); !ok || !got { + return fmt.Errorf("provider.ops.options.setCacheKey must be true") + } + models, _ := opsRaw["models"].(map[string]any) + if models == nil { + return fmt.Errorf("provider.ops.models missing") + } + expected := append([]string(nil), aliases...) + sort.Strings(expected) + actual := make([]string, 0, len(models)) + for alias, v := range models { + modelCfg, _ := v.(map[string]any) + if modelCfg == nil { + return fmt.Errorf("provider.ops.models.%s must be an object", alias) + } + if got, _ := modelCfg["name"].(string); got != alias { + return fmt.Errorf("provider.ops.models.%s.name mismatch", alias) + } + actual = append(actual, alias) + } + sort.Strings(actual) + if len(actual) != len(expected) { + return fmt.Errorf("provider.ops.models alias set mismatch") + } + for i := range actual { + if actual[i] != expected[i] { + return fmt.Errorf("provider.ops.models alias set mismatch") + } + } + return nil +} + // setIfDiff assigns key=val and returns true if the value actually changed. func setIfDiff(m map[string]any, key string, val any) bool { cur, ok := m[key] diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go new file mode 100644 index 0000000..ab0748a --- /dev/null +++ b/internal/opencode/opencode_test.go @@ -0,0 +1,294 @@ +package opencode + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGlobalConfigDirIgnoresOpencodeConfigDir(t *testing.T) { + t.Setenv("OPENCODE_CONFIG_DIR", "/tmp/custom-opencode") + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdg-home") + + got := GlobalConfigDir() + want := filepath.Join("/tmp/xdg-home", "opencode") + if got != want { + t.Fatalf("GlobalConfigDir() = %q, want %q", got, want) + } +} + +func TestResolveGlobalConfigPathPrecedence(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", root) + t.Setenv("OPENCODE_CONFIG_DIR", filepath.Join(root, "ignored")) + + dir := filepath.Join(root, "opencode") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + + path, existed := ResolveGlobalConfigPath() + if existed { + t.Fatalf("expected existed=false before files are created") + } + wantDefault := filepath.Join(dir, "opencode.jsonc") + if path != wantDefault { + t.Fatalf("default path = %q, want %q", path, wantDefault) + } + + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write config.json: %v", err) + } + path, existed = ResolveGlobalConfigPath() + if !existed || path != filepath.Join(dir, "config.json") { + t.Fatalf("expected config.json, got path=%q existed=%v", path, existed) + } + + if err := os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write opencode.json: %v", err) + } + path, existed = ResolveGlobalConfigPath() + if !existed || path != filepath.Join(dir, "opencode.json") { + t.Fatalf("expected opencode.json, got path=%q existed=%v", path, existed) + } + + if err := os.WriteFile(filepath.Join(dir, "opencode.jsonc"), []byte("{}\n"), 0o600); err != nil { + t.Fatalf("write opencode.jsonc: %v", err) + } + path, existed = ResolveGlobalConfigPath() + if !existed || path != filepath.Join(dir, "opencode.jsonc") { + t.Fatalf("expected opencode.jsonc, got path=%q existed=%v", path, existed) + } +} + +func TestValidateOpsProvider(t *testing.T) { + raw := Raw{} + aliases := []string{"gpt-5.4", "gpt-5.4-mini"} + baseURL := "http://127.0.0.1:9982/v1" + apiKey := "ops-local" + EnsureOpsProvider(raw, baseURL, apiKey, aliases) + + providerRaw, _ := raw["provider"].(map[string]any) + opsRaw, _ := providerRaw["ops"].(map[string]any) + opts, _ := opsRaw["options"].(map[string]any) + if got, ok := opts["setCacheKey"].(bool); !ok || !got { + t.Fatalf("provider.ops.options.setCacheKey = %#v, want true", opts["setCacheKey"]) + } + + if err := ValidateOpsProvider(raw, baseURL, apiKey, aliases); err != nil { + t.Fatalf("ValidateOpsProvider() unexpected error: %v", err) + } +} + +func TestRenderSaveDataReplacesExistingProviderOpsOnly(t *testing.T) { + raw := Raw{ + "$schema": "https://opencode.ai/config.json", + "model": "ops/gpt-5.4", + "provider": map[string]any{ + "anthropic": map[string]any{"npm": "@ai-sdk/anthropic"}, + "ops": map[string]any{ + "npm": "@ai-sdk/openai", + "name": "OPS", + "options": map[string]any{ + "baseURL": "http://127.0.0.1:9982/v1", + "apiKey": "ops-local", + "setCacheKey": true, + }, + "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, + }, + "openai": map[string]any{"npm": "@ai-sdk/openai"}, + }, + "small_model": "ops/gpt-5.4-mini", + } + original := []byte("{\n \"model\": \"ops/old\",\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"ops\": {\n \"npm\": \"old\",\n \"options\": {\"baseURL\": \"http://old/v1\"},\n \"models\": {\"old\": {\"name\": \"old\"}}\n },\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"small_model\": \"ops/old-mini\"\n}\n") + + got, err := patchProviderOpsDocument(original, raw) + if err != nil { + t.Fatalf("patchProviderOpsDocument() error: %v", err) + } + assertValidJSON(t, got) + assertStringOrder(t, string(got), []string{`"model"`, `"provider"`, `"small_model"`}) + assertStringOrder(t, string(got), []string{`"anthropic"`, `"ops"`, `"openai"`}) + if strings.Contains(string(got), `"npm": "old"`) { + t.Fatalf("old provider.ops content still present: %s", string(got)) + } + var saved Raw + if err := json.Unmarshal(got, &saved); err != nil { + t.Fatalf("unmarshal patched json: %v", err) + } + if err := ValidateOpsProvider(saved, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}); err != nil { + t.Fatalf("ValidateOpsProvider(saved) error: %v", err) + } +} + +func TestRenderSaveDataInsertsOpsWithoutReorderingProviderKeys(t *testing.T) { + raw := Raw{ + "provider": map[string]any{ + "anthropic": map[string]any{"npm": "@ai-sdk/anthropic"}, + "ops": map[string]any{ + "npm": "@ai-sdk/openai", + "name": "OPS", + "options": map[string]any{ + "baseURL": "http://127.0.0.1:9982/v1", + "apiKey": "ops-local", + "setCacheKey": true, + }, + "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, + }, + "openai": map[string]any{"npm": "@ai-sdk/openai"}, + }, + } + original := []byte("{\n \"provider\": {\n \"anthropic\": {\"npm\": \"@ai-sdk/anthropic\"},\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n },\n \"model\": \"ops/gpt-5.4\"\n}\n") + + got, err := patchProviderOpsDocument(original, raw) + if err != nil { + t.Fatalf("patchProviderOpsDocument() error: %v", err) + } + assertValidJSON(t, got) + assertStringOrder(t, string(got), []string{`"anthropic"`, `"openai"`, `"ops"`}) +} + +func TestRenderSaveDataInsertsProviderAtTopLevelEnd(t *testing.T) { + raw := Raw{ + "model": "ops/gpt-5.4", + "provider": map[string]any{ + "ops": map[string]any{ + "npm": "@ai-sdk/openai", + "name": "OPS", + "options": map[string]any{ + "baseURL": "http://127.0.0.1:9982/v1", + "apiKey": "ops-local", + "setCacheKey": true, + }, + "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, + }, + }, + "small_model": "ops/gpt-5.4-mini", + } + original := []byte("{\n \"model\": \"ops/gpt-5.4\",\n \"small_model\": \"ops/gpt-5.4-mini\"\n}\n") + + got, err := patchProviderOpsDocument(original, raw) + if err != nil { + t.Fatalf("patchProviderOpsDocument() error: %v", err) + } + assertValidJSON(t, got) + assertStringOrder(t, string(got), []string{`"model"`, `"small_model"`, `"provider"`}) +} + +func TestRenderSaveDataAcceptsJSONCAndProducesValidJSON(t *testing.T) { + raw := Raw{ + "provider": map[string]any{ + "ops": map[string]any{ + "npm": "@ai-sdk/openai", + "name": "OPS", + "options": map[string]any{ + "baseURL": "http://127.0.0.1:9982/v1", + "apiKey": "ops-local", + "setCacheKey": true, + }, + "models": map[string]any{"gpt-5.4": map[string]any{"name": "gpt-5.4"}}, + }, + }, + } + original := []byte("{\n // comment\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"},\n },\n}\n") + + got, err := patchProviderOpsDocument(original, raw) + if err != nil { + t.Fatalf("patchProviderOpsDocument() error: %v", err) + } + assertValidJSON(t, got) + if bytes.Contains(got, []byte("// comment")) { + t.Fatalf("expected normalized json output without comments, got %s", string(got)) + } +} + +func TestRenderSaveDataRejectsInvalidJSONC(t *testing.T) { + raw := Raw{ + "provider": map[string]any{ + "ops": map[string]any{ + "npm": "@ai-sdk/openai", + "name": "OPS", + "options": map[string]any{ + "baseURL": "http://127.0.0.1:9982/v1", + "apiKey": "ops-local", + "setCacheKey": true, + }, + }, + }, + } + + if _, err := patchProviderOpsDocument([]byte(`{"provider": {`), raw); err == nil { + t.Fatal("expected invalid json/jsonc error") + } +} + +func TestRenderSaveDataRejectsNonObjectProvider(t *testing.T) { + raw := Raw{} + EnsureOpsProvider(raw, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}) + + if _, err := patchProviderOpsDocument([]byte(`{"provider":"bad"}`), raw); err == nil { + t.Fatal("expected provider object error") + } +} + +func TestRenderSaveDataRejectsNonObjectTopLevel(t *testing.T) { + raw := Raw{} + EnsureOpsProvider(raw, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}) + + if _, err := patchProviderOpsDocument([]byte(`[]`), raw); err == nil { + t.Fatal("expected top-level object error") + } + if _, err := patchProviderOpsDocument([]byte("{} trailing"), raw); err == nil { + t.Fatal("expected single top-level object error") + } +} + +func TestRenderSaveDataWritesValidJSONToDisk(t *testing.T) { + path := filepath.Join(t.TempDir(), "opencode.jsonc") + if err := os.WriteFile(path, []byte("{\n \"model\": \"ops/gpt-5.4\",\n \"provider\": {\n \"openai\": {\"npm\": \"@ai-sdk/openai\"}\n }\n}\n"), 0o600); err != nil { + t.Fatalf("write seed config: %v", err) + } + raw := Raw{} + EnsureOpsProvider(raw, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}) + + if err := Save(path, raw); err != nil { + t.Fatalf("Save() error: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + assertValidJSON(t, got) + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load(saved) error: %v", err) + } + if err := ValidateOpsProvider(loaded, "http://127.0.0.1:9982/v1", "ops-local", []string{"gpt-5.4"}); err != nil { + t.Fatalf("ValidateOpsProvider(loaded) error: %v", err) + } +} + +func assertValidJSON(t *testing.T, data []byte) { + t.Helper() + if !json.Valid(data) { + t.Fatalf("invalid json output: %s", string(data)) + } +} + +func assertStringOrder(t *testing.T, body string, parts []string) { + t.Helper() + last := -1 + for _, part := range parts { + idx := strings.Index(body, part) + if idx < 0 { + t.Fatalf("missing %q in output: %s", part, body) + } + if idx < last { + t.Fatalf("order mismatch for %q in output: %s", part, body) + } + last = idx + } +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 06a0e0a..179eb37 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -20,6 +20,18 @@ import ( "github.com/anomalyco/opencode-provider-switch/internal/config" ) +var firstByteTimeout = 15 * time.Second + +type openAIErrorEnvelope struct { + Error openAIError `json:"error"` +} + +type openAIError struct { + Message string `json:"message"` + Type string `json:"type,omitempty"` + Code string `json:"code,omitempty"` +} + // Server is the local ops HTTP proxy. type Server struct { cfg *config.Config @@ -39,6 +51,7 @@ func New(cfg *config.Config) *Server { IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: firstByteTimeout, // no response buffering so streams flow through immediately DisableCompression: false, ForceAttemptHTTP2: true, @@ -88,7 +101,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { // does not rely on this, but clients sometimes probe it for connectivity. func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) { if !s.authorize(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) + writeOpenAIError(w, http.StatusUnauthorized, "invalid_api_key", "unauthorized") return } data := []map[string]any{} @@ -128,26 +141,26 @@ var reqCounter uint64 func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { reqID := atomic.AddUint64(&reqCounter, 1) if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") return } if !s.authorize(r) { - http.Error(w, "unauthorized", http.StatusUnauthorized) + writeOpenAIError(w, http.StatusUnauthorized, "invalid_api_key", "unauthorized") return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 50<<20)) if err != nil { - http.Error(w, "read body: "+err.Error(), http.StatusBadRequest) + writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "read body: "+err.Error()) return } var payload map[string]any if err := json.Unmarshal(body, &payload); err != nil { - http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest) + writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "invalid json: "+err.Error()) return } aliasName, _ := payload["model"].(string) if aliasName == "" { - http.Error(w, "missing model field", http.StatusBadRequest) + writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", "missing model field") return } rawModel := aliasName @@ -156,12 +169,12 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { alias := s.cfg.FindAlias(aliasName) if alias == nil { s.logger.Printf("req=%d alias lookup failed for model=%q alias=%q", reqID, rawModel, aliasName) - http.Error(w, fmt.Sprintf("alias %q not found", aliasName), http.StatusNotFound) + writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q not found", aliasName)) return } if !alias.Enabled { s.logger.Printf("req=%d alias=%q disabled", reqID, aliasName) - http.Error(w, fmt.Sprintf("alias %q is disabled", aliasName), http.StatusNotFound) + writeOpenAIError(w, http.StatusNotFound, "model_not_found", fmt.Sprintf("alias %q is disabled", aliasName)) return } var targets []config.Target @@ -172,7 +185,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { } if len(targets) == 0 { s.logger.Printf("req=%d alias=%q has no enabled targets", reqID, aliasName) - http.Error(w, fmt.Sprintf("alias %q has no enabled targets", aliasName), http.StatusFailedDependency) + writeOpenAIError(w, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("alias %q has no enabled targets", aliasName)) return } @@ -191,7 +204,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { newBody, err := json.Marshal(cloned) if err != nil { s.logger.Printf("req=%d marshal error: %v", reqID, err) - http.Error(w, "marshal error", http.StatusInternalServerError) + writeOpenAIError(w, http.StatusInternalServerError, "server_error", "marshal error") return } @@ -209,7 +222,7 @@ func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) { } // exhausted all targets with retryable failures - http.Error(w, fmt.Sprintf("all upstream targets failed for alias %q", aliasName), http.StatusBadGateway) + writeOpenAIError(w, http.StatusBadGateway, "server_error", fmt.Sprintf("all upstream targets failed for alias %q", aliasName)) } // tryOnce proxies one attempt. Returns (ok, retryable, err). @@ -242,6 +255,7 @@ func (s *Server) tryOnce( } upReq.ContentLength = int64(len(body)) + startedAt := time.Now() resp, err := s.client.Do(upReq) if err != nil { return false, true, fmt.Errorf("upstream dial/transport: %w", err) @@ -263,12 +277,40 @@ func (s *Server) tryOnce( return true, false, fmt.Errorf("upstream %d", resp.StatusCode) } + remaining := firstByteTimeout - time.Since(startedAt) + if remaining <= 0 { + _ = resp.Body.Close() + return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout) + } + firstChunk, firstErr := readFirstChunk(resp.Body, remaining) + if firstErr != nil { + if errors.Is(firstErr, errFirstByteTimeout) { + _ = resp.Body.Close() + return false, true, fmt.Errorf("upstream first byte timeout after %s", firstByteTimeout) + } + if errors.Is(firstErr, io.EOF) { + if len(firstChunk) == 0 { + firstChunk = nil + } + } else { + return false, true, fmt.Errorf("upstream first read: %w", firstErr) + } + } + // 2xx: start streaming pass-through. From this point no failover is allowed. s.logger.Printf("alias=%s attempt=%d provider=%s remote_model=%s upstream_status=%d", aliasName, attempt, provider.ID, target.Model, resp.StatusCode) s.writeDebugHeaders(w, aliasName, provider.ID, target.Model, attempt, failoverCount) copyResponseHeaders(w.Header(), resp.Header) w.WriteHeader(resp.StatusCode) flusher, _ := w.(http.Flusher) + if len(firstChunk) > 0 { + if _, werr := w.Write(firstChunk); werr != nil { + return true, false, werr + } + if flusher != nil { + flusher.Flush() + } + } buf := make([]byte, 16<<10) for { n, rerr := resp.Body.Read(buf) @@ -290,6 +332,52 @@ func (s *Server) tryOnce( } } +var errFirstByteTimeout = errors.New("first byte timeout") + +func readFirstChunk(r io.Reader, timeout time.Duration) ([]byte, error) { + type result struct { + buf []byte + err error + } + ch := make(chan result, 1) + go func() { + buf := make([]byte, 16<<10) + n, err := r.Read(buf) + if n > 0 { + buf = buf[:n] + } else { + buf = nil + } + ch <- result{buf: buf, err: err} + }() + select { + case res := <-ch: + return res.buf, res.err + case <-time.After(timeout): + return nil, errFirstByteTimeout + } +} + +func writeOpenAIError(w http.ResponseWriter, status int, code, message string) { + h := w.Header() + h.Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(openAIErrorEnvelope{ + Error: openAIError{ + Message: message, + Type: errorTypeForStatus(status), + Code: code, + }, + }) +} + +func errorTypeForStatus(status int) string { + if status >= 500 { + return "server_error" + } + return "invalid_request_error" +} + func normalizeAliasName(model string) string { const prefix = "ops/" if strings.HasPrefix(model, prefix) { diff --git a/internal/proxy/server_test.go b/internal/proxy/server_test.go new file mode 100644 index 0000000..142c423 --- /dev/null +++ b/internal/proxy/server_test.go @@ -0,0 +1,237 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/anomalyco/opencode-provider-switch/internal/config" +) + +func TestHandleResponsesWritesOpenAIErrorForMissingAlias(t *testing.T) { + t.Parallel() + + srv := New(&config.Config{ + Server: config.Server{APIKey: "ops-local"}, + }) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"missing","stream":true}`)) + req.Header.Set("Authorization", "Bearer ops-local") + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + srv.handleResponses(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } + assertOpenAIError(t, rr.Body.Bytes(), "model_not_found", "invalid_request_error", `alias "missing" not found`) +} + +func TestHandleResponsesFailsOverOn429(t *testing.T) { + t.Parallel() + + var firstSeenModel string + first := 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 first payload: %v", err) + } + firstSeenModel, _ = payload["model"].(string) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":{"message":"rate limit"}}`)) + })) + defer first.Close() + + var secondSeenModel string + second := 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 second payload: %v", err) + } + secondSeenModel, _ = payload["model"].(string) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: ok\n\n")) + })) + defer second.Close() + + srv := New(&config.Config{ + Server: config.Server{APIKey: "ops-local"}, + Providers: []config.Provider{ + {ID: "p1", BaseURL: first.URL + "/v1", APIKey: "sk-1"}, + {ID: "p2", BaseURL: second.URL + "/v1", APIKey: "sk-2"}, + }, + 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":"ops/gpt-5.4","stream":true}`)) + req.Header.Set("Authorization", "Bearer ops-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 body := rr.Body.String(); body != "data: ok\n\n" { + t.Fatalf("body = %q, want SSE payload", body) + } + if firstSeenModel != "up-1" { + t.Fatalf("first upstream model = %q, want up-1", firstSeenModel) + } + if secondSeenModel != "up-2" { + t.Fatalf("second upstream model = %q, want up-2", secondSeenModel) + } + if got := rr.Header().Get("X-OPS-Attempt"); got != "2" { + t.Fatalf("X-OPS-Attempt = %q, want 2", got) + } + if got := rr.Header().Get("X-OPS-Failover-Count"); got != "1" { + t.Fatalf("X-OPS-Failover-Count = %q, want 1", got) + } + if got := rr.Header().Get("X-OPS-Provider"); got != "p2" { + t.Fatalf("X-OPS-Provider = %q, want p2", got) + } +} + +func TestHandleResponsesDoesNotFailOverOn400(t *testing.T) { + t.Parallel() + + calledSecond := false + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"bad request"}}`)) + })) + defer first.Close() + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledSecond = true + w.WriteHeader(http.StatusOK) + })) + defer second.Close() + + srv := New(&config.Config{ + Server: config.Server{APIKey: "ops-local"}, + Providers: []config.Provider{ + {ID: "p1", BaseURL: first.URL + "/v1"}, + {ID: "p2", BaseURL: second.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 ops-local") + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + srv.handleResponses(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } + if calledSecond { + t.Fatal("second upstream should not be called for 400 response") + } + if got := rr.Header().Get("X-OPS-Provider"); got != "p1" { + t.Fatalf("X-OPS-Provider = %q, want p1", got) + } + if body := rr.Body.String(); body != `{"error":{"message":"bad request"}}` { + t.Fatalf("body = %q", body) + } +} + +func TestReadFirstChunk(t *testing.T) { + t.Parallel() + + t.Run("returns data", func(t *testing.T) { + t.Parallel() + buf, err := readFirstChunk(bytes.NewBufferString("abc"), 50*time.Millisecond) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(buf) != "abc" { + t.Fatalf("buf = %q, want abc", string(buf)) + } + }) + + t.Run("returns eof", func(t *testing.T) { + t.Parallel() + buf, err := readFirstChunk(bytes.NewReader(nil), 50*time.Millisecond) + if !errors.Is(err, io.EOF) { + t.Fatalf("err = %v, want EOF", err) + } + if buf != nil { + t.Fatalf("buf = %v, want nil", buf) + } + }) + + t.Run("returns data with eof", func(t *testing.T) { + t.Parallel() + buf, err := readFirstChunk(dataEOFReader{}, 50*time.Millisecond) + if !errors.Is(err, io.EOF) { + t.Fatalf("err = %v, want EOF", err) + } + if string(buf) != "abc" { + t.Fatalf("buf = %q, want abc", string(buf)) + } + }) + + t.Run("times out", func(t *testing.T) { + t.Parallel() + buf, err := readFirstChunk(blockingReader{}, 20*time.Millisecond) + if !errors.Is(err, errFirstByteTimeout) { + t.Fatalf("err = %v, want errFirstByteTimeout", err) + } + if buf != nil { + t.Fatalf("buf = %v, want nil", buf) + } + }) +} + +type blockingReader struct{} + +type dataEOFReader struct{} + +func (blockingReader) Read(p []byte) (int, error) { + time.Sleep(200 * time.Millisecond) + return 0, nil +} + +func (dataEOFReader) Read(p []byte) (int, error) { + copy(p, []byte("abc")) + return 3, io.EOF +} + +func assertOpenAIError(t *testing.T, body []byte, wantCode, wantType, wantMessage string) { + t.Helper() + var payload openAIErrorEnvelope + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal error body: %v", err) + } + if payload.Error.Code != wantCode { + t.Fatalf("error.code = %q, want %q", payload.Error.Code, wantCode) + } + if payload.Error.Type != wantType { + t.Fatalf("error.type = %q, want %q", payload.Error.Type, wantType) + } + if payload.Error.Message != wantMessage { + t.Fatalf("error.message = %q, want %q", payload.Error.Message, wantMessage) + } +}