fix: harden ops sync and proxy behavior

This commit is contained in:
apale7 2026-04-17 10:42:56 +08:00
parent a1f88db014
commit 37c6ee0640
12 changed files with 1256 additions and 22 deletions

View File

@ -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` 自己不会被反向导入

View File

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

View File

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

118
internal/cli/cli_test.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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