Files
ratatoskr-go/internal/opencode/client.go
Hermes ad9c2dd522
All checks were successful
CI / test (push) Successful in 41s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 35s
feat(config): уровень логирования log.level (info|debug), по умолчанию info
Отладочные логи API-вызовов к opencode serve теперь включаются только при
log.level=debug (гейт через Client.Debug <- Runner.Debug <- cfg.Log.Debug()).
По умолчанию 'info' — отладочных логов нет.

- config: добавлен LogCfg{Level}, дефолт 'info', валидация (info|debug)
- opencode: Client.Debug и Runner.Debug — гейттят логи do()
- app: Runner.Debug из cfg.Log.Debug()
- config.yaml.example: задокументирован log.level
- тесты: дефолт=info, level=debug, невалидный уровень
2026-08-18 18:44:37 +05:00

194 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package opencode
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
)
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
//
// Ходит по HTTP API opencode serve (v1.17+, префикс /api):
// - POST /api/session создать сессию → {data:{id}}
// - POST /api/session/:id/prompt отправить промпт {prompt:{text}}
// - POST /api/session/:id/wait дождаться завершения ответа (блок)
// - GET /api/session/:id/message?order=desc → {data:[{...}]} история ответов
// - POST /api/session/:id/interrupt прервать выполняющийся ответ
//
// Вердикт собирается из последнего assistant-сообщения: его content[] → текст
// тех частей, где type == "text". (В API-режиме текст в part.text — плоско,
// в отличие от NDJSON run, где он был вложен в part.part.text.)
type Client struct {
BaseURL string // http://host:port (без завершающего слеша)
Password string // basic auth (username "opencode")
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
http *http.Client
}
// ClientErr — классы ошибок клиента.
type ClientErr struct {
Op string // "connect" | "create" | "prompt" | "wait" | "messages" | "verdict"
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 выполняет запрос и возвращает тело ответа при 2xx. Иначе — ClientErr.
// op — метка операции (create/prompt/wait/messages/abort) для класса ошибки.
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
}
// 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, "/api/session", "create", b)
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
}
// Prompt отправляет промпт в сессию и ждёт завершения ответа (блокирующий wait).
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) error {
if err := c.Send(ctx, sessionID, prompt); err != nil {
return err
}
// POST /wait блокирует, пока ответ сессии не завершится.
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/wait", "wait", nil)
return err
}
// Send отправляет промпт в сессию (не ждёт ответа — выполнение идёт в фоне).
// Используется Runner'ом для асинхронного ожидания через поллинг сообщений.
func (c *Client) Send(ctx context.Context, sessionID, prompt string) error {
payload := map[string]any{"prompt": map[string]string{"text": prompt}}
b, _ := json.Marshal(payload)
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", b)
return err
}
// Abort прерывает выполняющийся ответ сессии.
func (c *Client) Abort(ctx context.Context, sessionID string) error {
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
return err
}
// sessionMessage — минимальная структура сообщения из GET /api/session/:id/message.
type sessionMessage struct {
Type string `json:"type"` // "assistant" | "user" | ...
Content []struct {
Type string `json:"type"` // "text" | "reasoning" | "tool"
Text string `json:"text"`
} `json:"content"`
}
// Verdict возвращает текст последнего assistant-сообщения сессии (вердикт).
// Ошибка — класса ErrVerdict (ClientErr{Op:"verdict"}), если нет готового
// assistant-сообщения или в нём нет text-частей.
func (c *Client) Verdict(ctx context.Context, sessionID string) (string, error) {
// order=desc — самые свежие сообщения первыми; идём по ним в поисках
// первого незаконченного assistant-ответа.
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=50", "messages", nil)
if err != nil {
return "", err
}
var out struct {
Data []sessionMessage `json:"data"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("невалидный ответ: %v", err)}
}
var b bytes.Buffer
for _, m := range out.Data {
if m.Type != "assistant" {
continue
}
b.Reset()
for _, p := range m.Content {
if p.Type == "text" && p.Text != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p.Text)
}
}
if b.Len() > 0 {
return stripFence(b.String()), nil
}
}
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("нет assistant-сообщения с text-частью")}
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}