package opencode import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "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") Directory string // каталог сервера — идёт заголовком x-opencode-directory Debug bool // включать отладочные логи API-вызовов (log.level=debug) http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send) } // SessionMessagesURL возвращает URL для просмотра сообщений сессии в браузере. // Сортировка по времени: сначала новые (order=desc). Без credentials — пароль // в лог не попадает. func SessionMessagesURL(baseURL, sessionID string) string { return baseURL + "/api/session/" + sessionID + "/message?order=desc" } // 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 body != nil { req.ContentLength = int64(len(body)) } if c.Password != "" { req.SetBasicAuth("opencode", c.Password) } req.Header.Set("Content-Type", "application/json") if c.Directory != "" { req.Header.Set("x-opencode-directory", c.Directory) } 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 { return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))} } 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. agent — имя агента // (analyst/dev/reviewer/chat/postmortem) из встроенных определений ratatoskr. // Модель мы НЕ выбираем: opencode сам берёт модель по умолчанию из своего // глобального конфига (ratatoskr про неё ничего не знает). func (c *Client) CreateSession(ctx context.Context, agent string) (string, error) { payload := map[string]any{} if agent != "" { payload["agent"] = agent } 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 сессии")} } time.Sleep(2 * time.Second) // Холостой вызов /api/model: прогревает сервер (модель/провайдеры), // чтобы первый промпт не спотыкался о «тёплый» старт. _, _ = c.do(ctx, http.MethodGet, "/api/model", "models-warmup", nil) time.Sleep(2 * time.Second) _, _ = c.do(ctx, http.MethodGet, "/api/model", "models-warmup", nil) 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 } // progressOf — «живой» прогресс новых assistant-сообщений: число контент-партов // (text/reasoning/tool) + суммарная длина их текста. Растёт во время стриминга, // когда один и тот же парт увеличивается (и при reasoning), — это и есть // сигнал, что LLM работает, а не висит. func progressOf(msgs []v2Message, since int64) (parts, textLen int) { for _, m := range assistantSince(msgs, since) { for _, p := range m.Content { parts++ if p.Type == "text" || p.Type == "reasoning" { textLen += len(p.Text) } } } return } // 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 } // assistantVerdict собирает финальный текст ответа: сначала text-парты, а если // их нет — только reasoning-парты (fallback для моделей, которые на некоторые // запросы отвечают лишь reasoning без text). usedReasoning=true означает, что // text-партов не было вовсе и вердикт собран из reasoning. func assistantVerdict(msgs []v2Message, since int64) (texts []string, usedReasoning bool) { if texts := assistantText(msgs, since); len(texts) > 0 { return texts, false } ass := assistantSince(msgs, since) reasoning := make([]string, 0, len(ass)) for i := len(ass) - 1; i >= 0; i-- { for _, p := range ass[i].Content { if p.Type == "reasoning" && p.Text != "" { reasoning = append(reasoning, p.Text) } } } return reasoning, len(reasoning) > 0 } // transcriptRole возвращает человекочитаемую подпись роли сообщения сессии. func transcriptRole(t string) string { switch t { case "user": return "Пользователь" case "assistant": return "Ассистент" case "tool": return "Инструмент" case "system": return "Система" default: return t } } // finishLabel — подпись для finish-reason (пусто/: опускаем). func finishLabel(f string) string { if f == "" || f == "stop" { return "" } return f } // renderTranscript форматирует сообщения сессии (API отдаёт новыми первыми) // в хронологическом порядке: роль, текст (и tool-вызовы), с обрезкой. func renderTranscript(msgs []v2Message) string { var b strings.Builder for i := len(msgs) - 1; i >= 0; i-- { m := &msgs[i] b.WriteString("\n== " + transcriptRole(m.Type)) if m.Type == "assistant" { if m.Model != nil { b.WriteString(" (" + m.Model.String() + ")") } if fl := finishLabel(m.Finish); fl != "" { b.WriteString(" finish=" + fl) } } b.WriteString(" ==\n") for _, p := range m.Content { if p.Text == "" { continue } b.WriteString(p.Text) if !strings.HasSuffix(p.Text, "\n") { b.WriteString("\n") } } } return b.String() } func truncateStr(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." }