- 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)
177 lines
5.4 KiB
Go
177 lines
5.4 KiB
Go
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
|
||
}
|