package opencode import ( "encoding/json" "fmt" "os" "path/filepath" "strings" ) // Чтение top-level "model" из эффективного конфига opencode. // // Зачем: ratatoskr хардпинит модель в сессии (CreateSession), чтобы не зависеть // от fallback-логики opencode. Если в конфиге модель не задана (или конфиг // написан по старой v1-схеме — npm/options, которые v2 молча игнорирует), // opencode сам выберет «дефолтную» модельную запись, и это может оказаться не // той моделью. Поэтому мы явно логируем предупреждение (класс O5 WARN). // opencodeConfigPath определяет путь к конфигу opencode, который видит // serve-процесс этого пула (см. README): (1) явный OPENCODE_CONFIG из Server // или окружения процесса, (2) OPENCODE_CONFIG_DIR / глобальный каталог // ~/.config/opencode. Возвращает "" если ничего не найдено. func opencodeConfigPath(cfgFile, cfgDir string) string { // (1) явный файл конфига — Server.Config или env OPENCODE_CONFIG. p := cfgFile if p == "" { p = os.Getenv("OPENCODE_CONFIG") } if p != "" { if st, err := os.Stat(p); err == nil && !st.IsDir() { return p } } // (2) каталог конфигов. dir := cfgDir if dir == "" { dir = os.Getenv("OPENCODE_CONFIG_DIR") } if dir == "" { home, err := os.UserHomeDir() if err != nil || home == "" { return "" } dir = filepath.Join(home, ".config", "opencode") if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { dir = filepath.Join(x, "opencode") } } for _, name := range []string{"opencode.json", "opencode.jsonc"} { cand := filepath.Join(dir, name) if st, err := os.Stat(cand); err == nil && !st.IsDir() { return cand } } return "" } // ReadModelRef извлекает top-level "model" из конфига opencode и возвращает // его как ModelRef. Модель не задана — вернёт (nil, nil); ошибка чтения/парсинга // возвращается (вызывающий логирует warning и продолжает без хардпина). func ReadModelRef(cfgFile, cfgDir string) (*ModelRef, error) { path := opencodeConfigPath(cfgFile, cfgDir) if path == "" { return nil, nil } b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config: читать %s: %w", path, err) } doc := struct { Model json.RawMessage `json:"model"` }{} if err := json.Unmarshal(stripJSONC(b), &doc); err != nil { return nil, fmt.Errorf("config: парсить %s: %w", path, err) } if len(doc.Model) == 0 || strings.TrimSpace(string(doc.Model)) == "null" { return nil, nil } // "model" может быть строкой "provider/id" или объектом {providerID, id}. var s string if err := json.Unmarshal(doc.Model, &s); err == nil { ref := parseModelString(s) if ref == nil { return nil, fmt.Errorf("config: некорректная model %q в %s (ожидается provider/id)", s, path) } return ref, nil } var ref ModelRef if err := json.Unmarshal(doc.Model, &ref); err != nil { return nil, fmt.Errorf("config: некорректная model в %s", path) } if ref.ProviderID == "" || ref.ID == "" { return nil, fmt.Errorf("config: model без providerID/id в %s", path) } return &ref, nil } // parseModelString разбирает "provider/id" (как ModelV2.parse: провайдер — всё // до первого '/', id — остаток). Возвращает nil при пустой/некорректной строке. func parseModelString(s string) *ModelRef { s = strings.TrimSpace(s) if s == "" { return nil } i := strings.IndexByte(s, '/') if i <= 0 || i == len(s)-1 { return nil } return &ModelRef{ProviderID: s[:i], ID: s[i+1:]} } // stripJSONC удаляет // и /* */ комментарии (вне строк), сохраняя позиции // переводов строк, чтобы json.Unmarshal не споткнулся о trailing-комма. func stripJSONC(b []byte) []byte { out := make([]byte, 0, len(b)) inStr := false esc := false i := 0 for i < len(b) { c := b[i] if inStr { out = append(out, c) if esc { esc = false } else if c == '\\' { esc = true } else if c == '"' { inStr = false } i++ continue } switch { case c == '"': inStr = true out = append(out, c) i++ case c == '/' && i+1 < len(b) && b[i+1] == '/': for i < len(b) && b[i] != '\n' { i++ } if i < len(b) { out = append(out, '\n') i++ } case c == '/' && i+1 < len(b) && b[i+1] == '*': i += 2 for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') { i++ } i += 2 default: out = append(out, c) i++ } } return dropTrailingCommas(out) } // dropTrailingCommas убирает запятые перед '}' / ']' (допускаются в JSONC). func dropTrailingCommas(b []byte) []byte { out := make([]byte, 0, len(b)) for i := 0; i < len(b); i++ { if b[i] == ',' { j := i + 1 for j < len(b) && (b[j] == ' ' || b[j] == '\t' || b[j] == '\n' || b[j] == '\r') { j++ } if j < len(b) && (b[j] == '}' || b[j] == ']') { continue } } out = append(out, b[i]) } return out }