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") 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") } 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 { 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))} } 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] + "..." }