feat(opencode): переход на v2 HTTP API opencode (хардпин модели, поллинг вердикта)
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

- 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:
ki.sagidullin
2026-08-19 08:29:11 +05:00
parent ad025c1668
commit 9f64be4ea5
10 changed files with 818 additions and 226 deletions

View File

@@ -11,29 +11,28 @@ import (
"time"
)
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
// Client — HTTP-взаимодействие с одним opencode serve (v2 HTTP API).
//
// Ходит по experimental HTTP API opencode serve (пути БЕЗ префикса /api):
// - POST /session создать сессию → голая Session {id}
// - POST /session/{id}/message отправить промпт {parts:[{type:"text"}]} →
// блокирует и возвращает {info,parts}; вердикт из parts
// - GET /session/{id}/message история → голый массив [{info, parts}] (для прогресса)
// - POST /session/{id}/abort прервать выполняющийся ответ
// Пути v2 начинаются с префикса /api (см. README, минимальная версия opencode):
// - POST /api/session создать сессию {model:{...}} → {data: Session.Info}
// - POST /api/session/{id}/prompt отправить промпт {prompt:{text}} →
// НЕБЛОКИРУЮЩЕ (admit) → {data: Admitted}
// - GET /api/session/{id}/message?order=desc → {data:[Message,...]}
// - POST /api/session/{id}/interrupt прервать активный ответ (204)
// - GET /api/session/active активные дренажи → {data:{sessionID:...}}
//
// Вердикт собирается из parts[] ответа на POST /message: текст тех частей,
// где type == "text".
// Prompt не блокирует: вердикт собирается поллингом из content[].type=="text"
// новых assistant-сообщений (см. Runner.awaitVerdict).
type Client struct {
BaseURL string // http://host:port (без завершающего слеша)
Password string // basic auth (username "opencode")
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
http *http.Client // для быстрых операций (create/messages/abort)
httpSend *http.Client // для блокирующего Send — без жёсткого таймаута,
// отменяется только через контекст (idle/hard)
http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send)
}
// ClientErr — классы ошибок клиента.
type ClientErr struct {
Op string // "connect" | "create" | "prompt" | "messages" | "abort"
Op string // "connect" | "create" | "prompt" | "messages" | "active" | "abort"
Err error
}
@@ -44,19 +43,10 @@ func (c *Client) defaults() {
if c.http == nil {
c.http = &http.Client{Timeout: 30 * time.Second}
}
if c.httpSend == nil {
c.httpSend = &http.Client{}
}
}
// do выполняет запрос через c.http (с таймаутом 30s) и возвращает тело при 2xx.
// do выполняет запрос через c.http и возвращает тело при 2xx.
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
c.defaults()
return c.doHTTP(ctx, method, path, op, body, c.http)
}
// doHTTP — общая реализация запроса; hc — клиент, которым выполняется запрос.
func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byte, hc *http.Client) ([]byte, error) {
c.defaults()
var rd io.Reader
if body != nil {
@@ -78,7 +68,7 @@ func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byt
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
}
}
resp, err := hc.Do(req)
resp, err := c.http.Do(req)
if err != nil {
return nil, &ClientErr{Op: "connect", Err: err}
}
@@ -99,116 +89,222 @@ func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byt
return b, nil
}
// CreateSession создаёт новую сессию и возвращает её id.
func (c *Client) CreateSession(ctx context.Context, title string) (string, error) {
body := map[string]string{}
if title != "" {
body["title"] = title
// ModelRef — ссылка на модель (аналог v2 Model.Ref: {providerID, id, variant?}).
// providerID — имя провайдера из конфига opencode, id — идентификатор модели.
type ModelRef struct {
ProviderID string `json:"providerID"`
ID string `json:"id"`
Variant string `json:"variant,omitempty"`
}
// String возвращает каноничное представление "provider/id[/variant]".
func (m *ModelRef) String() string {
if m == nil {
return ""
}
b, _ := json.Marshal(body)
raw, err := c.do(ctx, http.MethodPost, "/session", "create", b)
if m.Variant != "" {
return m.ProviderID + "/" + m.ID + "/" + m.Variant
}
return m.ProviderID + "/" + m.ID
}
// CreateSession создаёт новую сессию и возвращает её id. model != nil —
// хардпин модели (top-level "model" из конфига opencode), чтобы не зависеть
// от fallback-логики выбора модели в самом opencode.
func (c *Client) CreateSession(ctx context.Context, model *ModelRef) (string, error) {
payload := map[string]any{}
if model != nil {
payload["model"] = model
}
body, _ := json.Marshal(payload)
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", body)
if err != nil {
return "", err
}
// experimental: ответ — голая Session (без обёртки {data}).
var out struct {
ID string `json:"id"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.ID == "" {
if out.Data.ID == "" {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
}
return out.ID, nil
return out.Data.ID, nil
}
// Send отправляет промпт в сессию, БЛОКИРУЯСЬ до завершения ответа, и
// возвращает вердикт (текст text-частей из parts). Отмена — только через ctx
// (используется отдельный клиент без жёсткого таймаута; idle/hard в Runner'е
// отменяют контекст, что прерывает этот запрос).
func (c *Client) Send(ctx context.Context, sessionID, prompt string) (string, error) {
// Admitted — результат admit промпта (SessionInput.Admitted).
type Admitted struct {
ID string // id user-сообщения
TimeCreated int64 // epoch ms создания промпта (граница «новых» ответов)
}
// Prompt неблокирующе отправляет промпт в сессию (durable admit) и возвращает
// границу времени, с которой следует считать assistant-сообщения «новыми».
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) (*Admitted, error) {
payload := map[string]any{
"parts": []map[string]string{{"type": "text", "text": prompt}},
"prompt": map[string]string{"text": prompt},
}
b, _ := json.Marshal(payload)
c.defaults()
raw, err := c.doHTTP(ctx, http.MethodPost, "/session/"+sessionID+"/message", "prompt", b, c.httpSend)
body, _ := json.Marshal(payload)
raw, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", body)
if err != nil {
return "", err
return nil, err
}
var out struct {
Parts []part `json:"parts"`
Data struct {
ID string `json:"id"`
TimeCreated int64 `json:"timeCreated"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
var buf bytes.Buffer
for _, p := range out.Parts {
if p.Type == "text" && p.Text != "" {
if buf.Len() > 0 {
buf.WriteString("\n")
}
buf.WriteString(p.Text)
}
if out.Data.ID == "" {
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("пустой id промпта в ответе")}
}
if buf.Len() == 0 {
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("нет text-части в ответе")}
}
return stripFence(buf.String()), nil
return &Admitted{ID: out.Data.ID, TimeCreated: out.Data.TimeCreated}, nil
}
// Abort прерывает выполняющийся ответ сессии.
func (c *Client) Abort(ctx context.Context, sessionID string) error {
_, err := c.do(ctx, http.MethodPost, "/session/"+sessionID+"/abort", "abort", nil)
return err
// v2Message — минимальная проекция Session.Message (tagged union: тип в "type").
// Поле "role" в v2 отсутствует; assistant определяется по type=="assistant".
type v2Message struct {
ID string `json:"id"`
Type string `json:"type"` // "assistant" | "user" | "tool" | "system" | ...
Content []v2Part `json:"content"`
Model *ModelRef `json:"model"`
Finish string `json:"finish,omitempty"`
Error *v2Error `json:"error,omitempty"`
Time v2Time `json:"time"`
}
// part — минимальная часть сообщения (из parts[]).
type part struct {
type v2Part struct {
Type string `json:"type"` // "text" | "reasoning" | "tool" | ...
Text string `json:"text"`
}
// message — элемент голого массива из GET /session/{id}/message.
type message struct {
Info struct {
Role string `json:"role"` // "assistant" | "user" | ...
} `json:"info"`
Parts []part `json:"parts"`
type v2Time struct {
Created *int64 `json:"created"`
Completed *int64 `json:"completed"`
}
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
func (c *Client) messages(ctx context.Context, sessionID string) ([]message, error) {
raw, err := c.do(ctx, http.MethodGet, "/session/"+sessionID+"/message", "messages", nil)
type v2Error struct {
Type string `json:"type"`
Message string `json:"message"`
}
// finished — завершено ли assistant-сообщение (ответ агента закончен).
func (m *v2Message) finished() bool {
if m == nil {
return false
}
if m.Error != nil {
return true
}
if m.Finish != "" {
return true
}
return m.Time.Completed != nil && *m.Time.Completed > 0
}
// Messages возвращает сообщения сессии (новейшие первыми, до 200 за запрос).
func (c *Client) Messages(ctx context.Context, sessionID string) ([]v2Message, error) {
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=200", "messages", nil)
if err != nil {
return nil, err
}
var out []message
var out struct {
Data []v2Message `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
return nil, &ClientErr{Op: "messages", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
return out, nil
return out.Data, nil
}
// textCount считает число text-частей в assistant-сообщениях (для progress).
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
msgs, err := c.messages(ctx, sessionID)
// Active возвращает true, если сессия ещё обрабатывается (есть в активных
// дренажах этого serve). Сессии вне списка считаются завершёнными.
func (c *Client) Active(ctx context.Context, sessionID string) (bool, error) {
raw, err := c.do(ctx, http.MethodGet, "/api/session/active", "active", nil)
if err != nil {
return 0, err
return false, err
}
n := 0
for _, m := range msgs {
if m.Info.Role != "assistant" {
var out struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return false, &ClientErr{Op: "active", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
if out.Data == nil {
return false, nil
}
_, ok := out.Data[sessionID]
return ok, nil
}
// Interrupt прерывает активный ответ сессии (аналог v1 abort).
func (c *Client) Interrupt(ctx context.Context, sessionID string) error {
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
return err
}
// assistantSince фильтрует assistant-сообщения, созданные не раньше since
// (порядок сохраняется — как пришёл из API, новейшие первыми).
func assistantSince(msgs []v2Message, since int64) []*v2Message {
out := make([]*v2Message, 0, len(msgs))
for i := range msgs {
m := &msgs[i]
if m.Type != "assistant" {
continue
}
for _, p := range m.Parts {
if m.Time.Created == nil || *m.Time.Created < since {
continue
}
out = append(out, m)
}
return out
}
// textParts считает text-парты в одном assistant-сообщении (для прогресса).
func textParts(m *v2Message) int {
n := 0
for _, p := range m.Content {
if p.Type == "text" && p.Text != "" {
n++
}
}
return n
}
// newestAssistant возвращает самое новое assistant-сообщение (из фильтра) и
// суммарное число text-партов. since — граница времени (epoch ms).
func newestAssistant(msgs []v2Message, since int64) (*v2Message, int) {
ass := assistantSince(msgs, since)
var newest *v2Message
count := 0
for _, m := range ass {
count += textParts(m)
if newest == nil || *m.Time.Created > *newest.Time.Created {
newest = m
}
}
return newest, count
}
// assistantText объединяет text-парты новых assistant-сообщений в хронологическом
// порядке (сообщения приходят новейшими первыми → идём с конца).
func assistantText(msgs []v2Message, since int64) []string {
ass := assistantSince(msgs, since)
texts := make([]string, 0, len(ass))
for i := len(ass) - 1; i >= 0; i-- {
for _, p := range ass[i].Content {
if p.Type == "text" && p.Text != "" {
n++
texts = append(texts, p.Text)
}
}
}
return n, nil
return texts
}
func truncateStr(s string, n int) string {