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

607 lines
16 KiB
Go

// Package opencode reads and writes OpenCode config files, including the
// `provider.olpx` sync path. Files may be JSON or JSONC; we preserve the
// detected extension on write.
package opencode
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"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.
// MVP intentionally ignores OPENCODE_CONFIG_DIR for default sync scope; callers
// that want another file must pass --target explicitly.
func GlobalConfigDir() string {
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 provider.olpx back to path. Existing files are normalized to plain
// JSON and only the provider.olpx 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)
}
data, err := renderSaveData(path, raw)
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write tmp: %w", err)
}
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 := patchProviderOLPXDocument(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 patchProviderOLPXDocument(original []byte, raw Raw) ([]byte, error) {
olpxRaw, ok := olpxProviderValue(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{"olpx": olpxRaw})
}
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
}
olpx := providerObj.findMember("olpx")
if olpx == nil {
return insertObjectMember(normalized, providerObj, "olpx", olpxRaw)
}
return replaceObjectMember(normalized, *olpx, olpxRaw)
}
func olpxProviderValue(raw Raw) (map[string]any, bool) {
providerRaw, _ := raw["provider"].(map[string]any)
if providerRaw == nil {
return nil, false
}
olpxRaw, _ := providerRaw["olpx"].(map[string]any)
if olpxRaw == nil {
return nil, false
}
return olpxRaw, 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])
}
// EnsureOLPXProvider updates (or creates) the provider.olpx entry with the given
// local base URL, local api key and alias set. Existing keys on provider.olpx
// are preserved unless they conflict with the sync intent. For model entries,
// sync owns only the alias set: same-name model objects are left untouched so
// OpenCode-only metadata survives round-trips. Returns true if the file would
// actually change.
func EnsureOLPXProvider(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
}
olpxRaw, _ := provRaw["olpx"].(map[string]any)
if olpxRaw == nil {
olpxRaw = map[string]any{}
provRaw["olpx"] = olpxRaw
changed = true
}
if setIfDiff(olpxRaw, "npm", "@ai-sdk/openai") {
changed = true
}
if setIfDiff(olpxRaw, "name", "OpenCode LocalProxy CLI") {
changed = true
}
opts, _ := olpxRaw["options"].(map[string]any)
if opts == nil {
opts = map[string]any{}
olpxRaw["options"] = opts
changed = true
}
if setIfDiff(opts, "baseURL", baseURL) {
changed = true
}
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 objects
// verbatim if the alias key matches; drop aliases removed locally.
existingModels, _ := olpxRaw["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 {
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) {
olpxRaw["models"] = newModels
}
return changed
}
// ValidateOLPXProvider checks that provider.olpx matches the MVP sync contract.
func ValidateOLPXProvider(raw Raw, baseURL, apiKey string, aliases []string) error {
provRaw, _ := raw["provider"].(map[string]any)
if provRaw == nil {
return fmt.Errorf("missing provider object")
}
olpxRaw, _ := provRaw["olpx"].(map[string]any)
if olpxRaw == nil {
return fmt.Errorf("missing provider.olpx")
}
if npm, _ := olpxRaw["npm"].(string); npm != "@ai-sdk/openai" {
return fmt.Errorf("provider.olpx.npm must be @ai-sdk/openai")
}
if name, _ := olpxRaw["name"].(string); name != "OpenCode LocalProxy CLI" {
return fmt.Errorf("provider.olpx.name must be OpenCode LocalProxy CLI")
}
opts, _ := olpxRaw["options"].(map[string]any)
if opts == nil {
return fmt.Errorf("provider.olpx.options missing")
}
if got, _ := opts["baseURL"].(string); got != baseURL {
return fmt.Errorf("provider.olpx.options.baseURL mismatch")
}
if got, _ := opts["apiKey"].(string); got != apiKey {
return fmt.Errorf("provider.olpx.options.apiKey mismatch")
}
if got, ok := opts["setCacheKey"].(bool); !ok || !got {
return fmt.Errorf("provider.olpx.options.setCacheKey must be true")
}
models, _ := olpxRaw["models"].(map[string]any)
if models == nil {
return fmt.Errorf("provider.olpx.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.olpx.models.%s must be an object", alias)
}
actual = append(actual, alias)
}
sort.Strings(actual)
if len(actual) != len(expected) {
return fmt.Errorf("provider.olpx.models alias set mismatch")
}
for i := range actual {
if actual[i] != expected[i] {
return fmt.Errorf("provider.olpx.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]
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 `olpx` 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 == "olpx" {
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 {
return reflect.DeepEqual(a, b)
}