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