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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user