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)
This commit is contained in:
176
internal/opencode/config.go
Normal file
176
internal/opencode/config.go
Normal file
@@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user