Files
ratatoskr-go/internal/opencode/config.go
ki.sagidullin 9f64be4ea5
Some checks failed
CI / test (pull_request) Failing after 32s
CI / build-and-package (amd64, linux) (pull_request) Successful in 42s
CI / build-and-package (amd64, windows) (pull_request) Successful in 42s
feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)
- client.go: эндпоинты /api/* (create+model, prompt-admit, message, active, interrupt)
- runner.go: неблокирующий prompt + поллинг новых assistant-сообщений;
  завершение = сессия ушла из активных дренажей + стабильное финальное сообщение
- config.go: чтение top-level model из opencode.jsonc (JSONC-стрип) + хардпин в сессию
- server.go: healthcheck /api/health, MinVersion=1.18.18, понятная ошибка для старого бинаря
- класс O5 WARN: устойчивость к v1-конфигу провайдера (npm/options игнорируются v2)
- README: раздел интеграции, минимальная версия opencode, предупреждения
- .serena: актуализация памяти (core, tech_stack)
2026-08-19 08:29:11 +05:00

177 lines
5.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}