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