Корень проблемы «не получаем результаты»: клиент смешивал два слоя opencode
serve. CreateSession ходил на /api/session (v2, ждал {data.id}), Verdict — на
/api/session/{id}/message?order=desc и ждал {data:[{type,content}]}, где поле
content[].type/text физически отсутствует, поэтому вердикт никогда не находился
и поллинг уходил в вечный таймаут. Abort и вовсе звал несуществующий /interrupt.
Теперь весь код на experimental-слое, как сверено с sst/opencode (ветка dev):
- CreateSession: POST /session → голая Session, id в .id.
- Send: блокирующий POST /session/{id}/message, тело {parts:[{type:text,text}]},
вердикт из частей parts[].type=="text" ответа. Это и есть результат — метод
Verdict и отдельный GET удалены.
- textCount (прогресс): GET /session/{id}/message → голый массив [{info, parts}].
- Abort: POST /session/{id}/abort.
Runner: блокирующий Send запускается в горутине (канал вердикта/ошибки),
параллельно поллим textCount (рост text-частей сбрасывает idle-таймер). При
idle/hard-таймауте или отмене контекста — Abort + cancel() Send-горутины → rc=-1.
Send ходит через отдельный http.Client без жёсткого Timeout (управляется ctx),
чтобы длинная генерация не обрывалась на 30s. Тесты/fakeAPIServer переведены на
экспериментальный формат. Версия → 0.2.2.
220 lines
7.5 KiB
Go
220 lines
7.5 KiB
Go
package opencode
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// Client — HTTP-взаимодействие с одним opencode serve (режим 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 прервать выполняющийся ответ
|
||
//
|
||
// Вердикт собирается из parts[] ответа на POST /message: текст тех частей,
|
||
// где type == "text".
|
||
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)
|
||
}
|
||
|
||
// ClientErr — классы ошибок клиента.
|
||
type ClientErr struct {
|
||
Op string // "connect" | "create" | "prompt" | "messages" | "abort"
|
||
Err error
|
||
}
|
||
|
||
func (e *ClientErr) Error() string { return fmt.Sprintf("opencode api %s: %v", e.Op, e.Err) }
|
||
func (e *ClientErr) Unwrap() error { return e.Err }
|
||
|
||
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.
|
||
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 {
|
||
rd = bytes.NewReader(body)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
||
if err != nil {
|
||
return nil, &ClientErr{Op: "connect", Err: err}
|
||
}
|
||
if c.Password != "" {
|
||
req.SetBasicAuth("opencode", c.Password)
|
||
}
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
if c.Debug {
|
||
log.Printf("opencode api %s -> %s %s%s", op, method, c.BaseURL, path)
|
||
if len(body) > 0 {
|
||
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
|
||
}
|
||
}
|
||
resp, err := hc.Do(req)
|
||
if err != nil {
|
||
return nil, &ClientErr{Op: "connect", Err: err}
|
||
}
|
||
defer resp.Body.Close()
|
||
b, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, &ClientErr{Op: "connect", Err: err}
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||
if c.Debug {
|
||
log.Printf("opencode api %s response: status %d: %s", op, resp.StatusCode, truncateStr(string(b), 1000))
|
||
}
|
||
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
|
||
}
|
||
if c.Debug {
|
||
log.Printf("opencode api %s response (%d bytes): %s", op, len(b), truncateStr(string(b), 5000))
|
||
}
|
||
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
|
||
}
|
||
b, _ := json.Marshal(body)
|
||
raw, err := c.do(ctx, http.MethodPost, "/session", "create", b)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// experimental: ответ — голая Session (без обёртки {data}).
|
||
var out struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||
}
|
||
if out.ID == "" {
|
||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||
}
|
||
return out.ID, nil
|
||
}
|
||
|
||
// Send отправляет промпт в сессию, БЛОКИРУЯСЬ до завершения ответа, и
|
||
// возвращает вердикт (текст text-частей из parts). Отмена — только через ctx
|
||
// (используется отдельный клиент без жёсткого таймаута; idle/hard в Runner'е
|
||
// отменяют контекст, что прерывает этот запрос).
|
||
func (c *Client) Send(ctx context.Context, sessionID, prompt string) (string, error) {
|
||
payload := map[string]any{
|
||
"parts": []map[string]string{{"type": "text", "text": prompt}},
|
||
}
|
||
b, _ := json.Marshal(payload)
|
||
c.defaults()
|
||
raw, err := c.doHTTP(ctx, http.MethodPost, "/session/"+sessionID+"/message", "prompt", b, c.httpSend)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
var out struct {
|
||
Parts []part `json:"parts"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return "", &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 buf.Len() == 0 {
|
||
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("нет text-части в ответе")}
|
||
}
|
||
return stripFence(buf.String()), 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
|
||
}
|
||
|
||
// part — минимальная часть сообщения (из parts[]).
|
||
type part 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"`
|
||
}
|
||
|
||
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
|
||
func (c *Client) messages(ctx context.Context, sessionID string) ([]message, error) {
|
||
raw, err := c.do(ctx, http.MethodGet, "/session/"+sessionID+"/message", "messages", nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out []message
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// textCount считает число text-частей в assistant-сообщениях (для progress).
|
||
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
|
||
msgs, err := c.messages(ctx, sessionID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
n := 0
|
||
for _, m := range msgs {
|
||
if m.Info.Role != "assistant" {
|
||
continue
|
||
}
|
||
for _, p := range m.Parts {
|
||
if p.Type == "text" && p.Text != "" {
|
||
n++
|
||
}
|
||
}
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
func truncateStr(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "..."
|
||
}
|