opencode-provider-switch/internal/opencode/opencode.go

249 lines
6.4 KiB
Go

// Package opencode reads and writes OpenCode config files, including the
// `provider.ops` sync path. Files may be JSON or JSONC; we preserve the
// detected extension on write.
package opencode
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/tidwall/jsonc"
)
// ConfigFileCandidates is the precedence order inside the global config dir.
var ConfigFileCandidates = []string{"opencode.jsonc", "opencode.json", "config.json"}
// GlobalConfigDir returns the default user-global OpenCode config directory.
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")
}
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return filepath.Join(home, ".config", "opencode")
}
// ResolveGlobalConfigPath returns the existing global config file if any,
// otherwise the default target path (opencode.jsonc).
func ResolveGlobalConfigPath() (path string, existed bool) {
dir := GlobalConfigDir()
for _, name := range ConfigFileCandidates {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p, true
}
}
return filepath.Join(dir, "opencode.jsonc"), false
}
// Raw is the JSON object form of an OpenCode config. We treat it as a generic
// map so unknown fields pass through untouched on write.
type Raw map[string]any
// Load reads an OpenCode config file. Missing files yield an empty Raw.
// JSONC (line/block comments, trailing commas) is supported on read, but the
// write side always emits plain JSON (comments are not preserved).
func Load(path string) (Raw, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Raw{}, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
if len(data) == 0 {
return Raw{}, nil
}
stripped := jsonc.ToJSON(data)
out := Raw{}
if err := json.Unmarshal(stripped, &out); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return out, nil
}
// Save writes Raw back to path. Parent dirs are created. 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, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
data = append(data, '\n')
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write tmp: %w", err)
}
return os.Rename(tmp, path)
}
// 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
// file would actually change.
func EnsureOpsProvider(raw Raw, baseURL, apiKey string, aliases []string) bool {
changed := false
if _, ok := raw["$schema"]; !ok {
raw["$schema"] = "https://opencode.ai/config.json"
changed = true
}
provRaw, _ := raw["provider"].(map[string]any)
if provRaw == nil {
provRaw = map[string]any{}
raw["provider"] = provRaw
changed = true
}
opsRaw, _ := provRaw["ops"].(map[string]any)
if opsRaw == nil {
opsRaw = map[string]any{}
provRaw["ops"] = opsRaw
changed = true
}
if setIfDiff(opsRaw, "npm", "@ai-sdk/openai") {
changed = true
}
if setIfDiff(opsRaw, "name", "OPS") {
changed = true
}
opts, _ := opsRaw["options"].(map[string]any)
if opts == nil {
opts = map[string]any{}
opsRaw["options"] = opts
changed = true
}
if setIfDiff(opts, "baseURL", baseURL) {
changed = true
}
if setIfDiff(opts, "apiKey", apiKey) {
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)
newModels := map[string]any{}
aliasSet := map[string]bool{}
for _, a := range aliases {
aliasSet[a] = true
if existing, ok := existingModels[a].(map[string]any); ok {
// make sure "name" stays consistent with alias key
if setIfDiff(existing, "name", a) {
changed = true
}
newModels[a] = existing
} else {
newModels[a] = map[string]any{"name": a}
changed = true
}
}
// removed entries?
for k := range existingModels {
if !aliasSet[k] {
changed = true
}
}
if !mapsEqualShallow(existingModels, newModels) {
opsRaw["models"] = newModels
}
return changed
}
// 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]
if ok && cur == val {
return false
}
m[key] = val
return true
}
// ImportableProvider is a subset extracted from an OpenCode custom provider.
type ImportableProvider struct {
ID string
Name string
BaseURL string
APIKey string
Models []string
}
// ImportCustomProviders scans raw for @ai-sdk/openai custom providers that
// declare baseURL and an apiKey-compatible setting. The `ops` id itself is
// skipped so sync output is not re-imported.
func ImportCustomProviders(raw Raw) []ImportableProvider {
out := []ImportableProvider{}
provRaw, _ := raw["provider"].(map[string]any)
for id, v := range provRaw {
if id == "ops" {
continue
}
m, ok := v.(map[string]any)
if !ok {
continue
}
npm, _ := m["npm"].(string)
if npm != "@ai-sdk/openai" {
continue
}
opts, _ := m["options"].(map[string]any)
if opts == nil {
continue
}
baseURL, _ := opts["baseURL"].(string)
apiKey, _ := opts["apiKey"].(string)
if baseURL == "" {
continue
}
ip := ImportableProvider{
ID: id,
BaseURL: baseURL,
APIKey: apiKey,
}
if n, ok := m["name"].(string); ok {
ip.Name = n
}
if models, ok := m["models"].(map[string]any); ok {
for k := range models {
ip.Models = append(ip.Models, k)
}
}
out = append(out, ip)
}
return out
}
// mapsEqualShallow compares two string-keyed maps for structural equality.
func mapsEqualShallow(a, b map[string]any) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
bv, ok := b[k]
if !ok {
return false
}
am, amOk := v.(map[string]any)
bm, bmOk := bv.(map[string]any)
if amOk && bmOk {
if !mapsEqualShallow(am, bm) {
return false
}
continue
}
if v != bv {
return false
}
}
return true
}