Растущий в один text-парт стрим (text-delta) и reasoning больше не выглядят как зависшая нейронка: idle-таймер сбрасывается по росту числа партов и суммарной длины text/reasoning.
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package opencode
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
// Client — HTTP-взаимодействие с одним opencode serve (v2 HTTP API).
|
||
//
|
||
// Пути 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:...}}
|
||
//
|
||
// 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 // единый клиент: все операции быстрые (нет блокирующего Send)
|
||
}
|
||
|
||
// ClientErr — классы ошибок клиента.
|
||
type ClientErr struct {
|
||
Op string // "connect" | "create" | "prompt" | "messages" | "active" | "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}
|
||
}
|
||
}
|
||
|
||
// do выполняет запрос через c.http и возвращает тело при 2xx.
|
||
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]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 := c.http.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
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
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
|
||
}
|
||
var out struct {
|
||
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.Data.ID == "" {
|
||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||
}
|
||
return out.Data.ID, nil
|
||
}
|
||
|
||
// 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{
|
||
"prompt": map[string]string{"text": prompt},
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
raw, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out struct {
|
||
Data struct {
|
||
ID string `json:"id"`
|
||
TimeCreated int64 `json:"timeCreated"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||
}
|
||
if out.Data.ID == "" {
|
||
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("пустой id промпта в ответе")}
|
||
}
|
||
return &Admitted{ID: out.Data.ID, TimeCreated: out.Data.TimeCreated}, nil
|
||
}
|
||
|
||
// 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"`
|
||
}
|
||
|
||
type v2Part struct {
|
||
Type string `json:"type"` // "text" | "reasoning" | "tool" | ...
|
||
Text string `json:"text"`
|
||
}
|
||
|
||
type v2Time struct {
|
||
Created *int64 `json:"created"`
|
||
Completed *int64 `json:"completed"`
|
||
}
|
||
|
||
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 struct {
|
||
Data []v2Message `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, &ClientErr{Op: "messages", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||
}
|
||
return out.Data, nil
|
||
}
|
||
|
||
// 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 false, err
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
|
||
// progressOf — «живой» прогресс новых assistant-сообщений: число контент-партов
|
||
// (text/reasoning/tool) + суммарная длина их текста. Растёт во время стриминга,
|
||
// когда один и тот же парт увеличивается (и при reasoning), — это и есть
|
||
// сигнал, что LLM работает, а не висит.
|
||
func progressOf(msgs []v2Message, since int64) (parts, textLen int) {
|
||
for _, m := range assistantSince(msgs, since) {
|
||
for _, p := range m.Content {
|
||
parts++
|
||
if p.Type == "text" || p.Type == "reasoning" {
|
||
textLen += len(p.Text)
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// 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 != "" {
|
||
texts = append(texts, p.Text)
|
||
}
|
||
}
|
||
}
|
||
return texts
|
||
}
|
||
|
||
func truncateStr(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "..."
|
||
}
|