fix: harden ops sync and proxy behavior
This commit is contained in:
parent
a1f88db014
commit
37c6ee0640
@ -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` 自己不会被反向导入
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
118
internal/cli/cli_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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{}
|
||||
|
||||
38
internal/config/config_test.go
Normal file
38
internal/config/config_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
|
||||
294
internal/opencode/opencode_test.go
Normal file
294
internal/opencode/opencode_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
237
internal/proxy/server_test.go
Normal file
237
internal/proxy/server_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user