more oc fixes

This commit is contained in:
Luxferre
2026-09-18 12:08:01 +03:00
parent 0b2d9f2a90
commit f23c9132ce
3 changed files with 162 additions and 26 deletions
+2 -2
View File
@@ -102,7 +102,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
1. **Initialization**: Read the config file (`.bantam.cfg` if present, else `model.cfg`). Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the built-in system prompt `{"role": "system", "content": system_prompt}`. 1. **Initialization**: Read the config file (`.bantam.cfg` if present, else `model.cfg`). Discover context window size from the `/models` endpoint (or fallback to `context_window` from `model.cfg` or 200,000 tokens). Prepare an array of messages starting with the built-in system prompt `{"role": "system", "content": system_prompt}`.
2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`. 2. **Input Processing**: Take user prompt (via command-line file parameter or interactive stdin). If prefixed with `!`, execute the command directly via `shell_exec` without appending to conversation context. Otherwise, append `{"role": "user", "content": prompt}`, and invoke `AL(cfg, messages)`.
3. **Agentic Loop (`AL`)**: 3. **Agentic Loop (`AL`)**:
- Send `messages` and tool definitions (`shell_exec`, `write_file`) to the OpenAI-compatible `/chat/completions` API endpoint with custom `User-Agent` headers and `stream_options: {"include_usage": true}`. - Send `messages` and tool definitions (`shell_exec`, `write_file`) to the OpenAI-compatible `/chat/completions` API endpoint with custom request headers (see below) and `stream_options: {"include_usage": true}`.
- Support context cancellation (e.g. on `SIGINT` / Ctrl+C) to cleanly abort in-flight requests without appending incomplete messages. - Support context cancellation (e.g. on `SIGINT` / Ctrl+C) to cleanly abort in-flight requests without appending incomplete messages.
- On network or HTTP failure, retry using Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`). - On network or HTTP failure, retry using Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
- If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers, rendering Markdown and tables constrained to terminal width. - If `stream=true`, parse SSE data chunks (`data: {...}`) in real-time to stream reasoning content (`reasoning_content`) and response text directly to stdout, bracketing the reasoning block with `--- reasoning start ---` / `--- reasoning end ---` markers, rendering Markdown and tables constrained to terminal width.
@@ -125,7 +125,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
### Agentic loop (`AL(cfg, messages)`) function ### Agentic loop (`AL(cfg, messages)`) function
1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) forwarding relevant parameters from `cfg` (`model`, `temperature`, `stream`, `reasoning_effort`, etc., excluding internal agent configs like `endpoint`, `api_key`, `timeout`, `shell_timeout`, `max_al_iterations`, `color`, `context_window`) and optional `api_key` bearer header. 1. Call OpenAI-compatible Completions API (`POST {endpoint}/chat/completions`) forwarding relevant parameters from `cfg` (`model`, `temperature`, `stream`, `reasoning_effort`, etc., excluding internal agent configs like `endpoint`, `api_key`, `timeout`, `shell_timeout`, `max_al_iterations`, `color`, `context_window`) and optional `api_key` bearer header.
- Set custom `User-Agent` header (`Mozilla/5.0 (compatible; Bantam/1.0)`) to avoid gateway 403 blocks. - Set custom request headers. If the endpoint host is `opencode.ai` (or a subdomain), send the OpenCode Zen header set: `User-Agent: opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14`, `x-opencode-client: cli`, `x-opencode-project: <project-md5>`, `x-opencode-session: <ses_...>`, and a fresh `x-opencode-request: msg_<26 base32 chars>` per call. For every other endpoint, keep the legacy `User-Agent: opencode/1.18.31` plus `X-Session-Id` and `x-session-affinity`. The `Authorization: Bearer <api_key>` header is added whenever `api_key` is set and not `-`.
- Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`). - Retry network/HTTP errors with Fibonacci backoff delays (`1s, 1s, 2s, 3s, 5s, 8s, 13s, 21s, 34s`).
- If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers. - If `stream=true`, parse SSE stream (`data: {...}`) for real-time reasoning and text output, bracketing reasoning with `--- reasoning start ---` / `--- reasoning end ---` markers.
2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers. 2. Append the assistant's response message object to `messages`. If non-streaming and response has reasoning tokens (`reasoning_content` or `reasoning`), output them wrapped in `--- reasoning start ---` / `--- reasoning end ---` markers.
+58 -18
View File
@@ -8,6 +8,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/md5" "crypto/md5"
"crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -87,6 +88,8 @@ var llmTransport = &http.Transport{
var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 15000, map[string]string{"reasoning_effort": "high"}} var defCfg = Cfg{"https://api.kilo.ai/api/openrouter", "openrouter/free", "-", 0.7, 300, 120, 1000, true, "auto", 262144, 15000, map[string]string{"reasoning_effort": "high"}}
const opencodeAgentVersion = "opencode/1.18.31" const opencodeAgentVersion = "opencode/1.18.31"
const opencodeProviderUA = "ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14"
const opencodeIDAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func atoiD(s string, d int) int { func atoiD(s string, d int) int {
if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil { if v, e := strconv.Atoi(strings.TrimSpace(s)); e == nil {
@@ -105,12 +108,7 @@ func queryModelsContextWindow(cfg *Cfg) int {
} }
req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil)
if err != nil { return 0 } if err != nil { return 0 }
req.Header.Set("User-Agent", opencodeAgentVersion) applyLLMHeaders(req, cfg, ocRequestID())
req.Header.Set("X-Session-Id", projectID())
req.Header.Set("x-opencode-project", projectID())
req.Header.Set("x-opencode-client", "cli")
req.Header.Set("x-opencode-session", opencodeSessionID())
if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil || resp.StatusCode >= 400 { return 0 } if err != nil || resp.StatusCode >= 400 { return 0 }
defer resp.Body.Close() defer resp.Body.Close()
@@ -181,12 +179,7 @@ func listModels(cfg *Cfg) (string, error) {
} }
req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil) req, err := http.NewRequest("GET", strings.TrimRight(cfg.Endpoint, "/")+"/models", nil)
if err != nil { return "", err } if err != nil { return "", err }
req.Header.Set("User-Agent", opencodeAgentVersion) applyLLMHeaders(req, cfg, ocRequestID())
req.Header.Set("X-Session-Id", projectID())
req.Header.Set("x-opencode-project", projectID())
req.Header.Set("x-opencode-client", "cli")
req.Header.Set("x-opencode-session", opencodeSessionID())
if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { return "", err } if err != nil { return "", err }
defer resp.Body.Close() defer resp.Body.Close()
@@ -1052,6 +1045,7 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
pend := c("...requesting...", 1, 2) pend := c("...requesting...", 1, 2)
var resp *http.Response var resp *http.Response
var err error var err error
reqID := ocRequestID()
for i := 0; i <= len(fib); i++ { for i := 0; i <= len(fib); i++ {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
if COL { fmt.Print("\r\033[K") } if COL { fmt.Print("\r\033[K") }
@@ -1060,12 +1054,7 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) } if COL { fmt.Print("\r" + pend) } else { fmt.Println(pend) }
req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body)) req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", opencodeAgentVersion) applyLLMHeaders(req, cfg, reqID)
req.Header.Set("X-Session-Id", projectID())
req.Header.Set("x-opencode-project", projectID())
req.Header.Set("x-opencode-client", "cli")
req.Header.Set("x-opencode-session", opencodeSessionID())
if cfg.APIKey != "" && cfg.APIKey != "-" { req.Header.Set("Authorization", "Bearer "+cfg.APIKey) }
resp, err = client.Do(req) resp, err = client.Do(req)
var is4xxClientErr bool var is4xxClientErr bool
if err == nil && resp.StatusCode >= 400 { if err == nil && resp.StatusCode >= 400 {
@@ -1694,6 +1683,57 @@ func opencodeSessionID() string {
return "ses_" + pid return "ses_" + pid
} }
// isOpencodeEndpoint reports whether the endpoint belongs to OpenCode Zen and
// therefore expects the x-opencode-* request headers.
func isOpencodeEndpoint(endpoint string) bool {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil || u.Hostname() == "" {
return strings.Contains(strings.ToLower(endpoint), "opencode.ai")
}
h := strings.ToLower(u.Hostname())
return h == "opencode.ai" || strings.HasSuffix(h, ".opencode.ai")
}
// ocRequestID returns a fresh OpenCode-style message id (msg_ followed by 26
// base32 characters) matching the ids sent in the x-opencode-request header.
func ocRequestID() string {
buf := make([]byte, 26)
if _, err := rand.Read(buf); err != nil {
h := md5.Sum([]byte(fmt.Sprintf("%d-%d", time.Now().UnixNano(), os.Getpid())))
for i := range buf {
buf[i] = h[i%len(h)]
}
}
var b strings.Builder
b.WriteString("msg_")
for _, x := range buf {
b.WriteByte(opencodeIDAlphabet[int(x)&31])
}
return b.String()
}
// applyLLMHeaders sets the request headers appropriate for the configured
// endpoint. OpenCode (Zen) endpoints receive the x-opencode-* family plus the
// full provider User-Agent; all other endpoints keep the legacy
// session-affinity headers. msgID is used for the x-opencode-request header.
func applyLLMHeaders(req *http.Request, cfg *Cfg, msgID string) {
sid := opencodeSessionID()
if isOpencodeEndpoint(cfg.Endpoint) {
req.Header.Set("User-Agent", opencodeAgentVersion+" "+opencodeProviderUA)
req.Header.Set("x-opencode-client", "cli")
req.Header.Set("x-opencode-project", projectID())
req.Header.Set("x-opencode-session", sid)
req.Header.Set("x-opencode-request", msgID)
} else {
req.Header.Set("User-Agent", opencodeAgentVersion)
req.Header.Set("x-session-affinity", sid)
req.Header.Set("X-Session-Id", sid)
}
if cfg.APIKey != "" && cfg.APIKey != "-" {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
}
func saveSession(msgs []Message, sid string) (string, string) { func saveSession(msgs []Message, sid string) (string, string) {
sid = strings.TrimSpace(sid) sid = strings.TrimSpace(sid)
if sid == "" { if sid == "" {
+102 -6
View File
@@ -1147,13 +1147,15 @@ func TestCol(t *testing.T) {
// ---------- llm / AL / summarize / compact via httptest (no real network) ---------- // ---------- llm / AL / summarize / compact via httptest (no real network) ----------
func TestLLMNonStreamingAndHeaders(t *testing.T) { func TestLLMNonStreamingAndHeaders(t *testing.T) {
var gotPath, gotAuth, gotUA, gotOCClient, gotOCSession string var gotPath, gotAuth, gotUA, gotLegacy, gotAffinity, gotOCClient, gotOCReq string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization") gotAuth = r.Header.Get("Authorization")
gotUA = r.Header.Get("User-Agent") gotUA = r.Header.Get("User-Agent")
gotLegacy = r.Header.Get("X-Session-Id")
gotAffinity = r.Header.Get("x-session-affinity")
gotOCClient = r.Header.Get("x-opencode-client") gotOCClient = r.Header.Get("x-opencode-client")
gotOCSession = r.Header.Get("x-opencode-session") gotOCReq = r.Header.Get("x-opencode-request")
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi","reasoning_content":"think"}}]}`)) w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hi","reasoning_content":"think"}}]}`))
})) }))
defer srv.Close() defer srv.Close()
@@ -1175,17 +1177,111 @@ func TestLLMNonStreamingAndHeaders(t *testing.T) {
if !strings.Contains(gotUA, "opencode/1.18.31") { if !strings.Contains(gotUA, "opencode/1.18.31") {
t.Errorf("user-agent = %q", gotUA) t.Errorf("user-agent = %q", gotUA)
} }
if gotOCClient != "cli" { // Non-OpenCode endpoints keep the legacy session-affinity headers and must
t.Errorf("x-opencode-client = %q, want cli", gotOCClient) // not receive any x-opencode-* headers.
if gotLegacy != opencodeSessionID() {
t.Errorf("X-Session-Id = %q, want %q", gotLegacy, opencodeSessionID())
} }
if gotOCSession != opencodeSessionID() { if gotAffinity != opencodeSessionID() {
t.Errorf("x-opencode-session = %q, want %q", gotOCSession, opencodeSessionID()) t.Errorf("x-session-affinity = %q, want %q", gotAffinity, opencodeSessionID())
}
if gotOCClient != "" || gotOCReq != "" {
t.Errorf("unexpected x-opencode headers: client=%q request=%q", gotOCClient, gotOCReq)
} }
if m.Content == nil || *m.Content != "hi" || m.ReasoningContent != "think" { if m.Content == nil || *m.Content != "hi" || m.ReasoningContent != "think" {
t.Errorf("message = %+v", m) t.Errorf("message = %+v", m)
} }
} }
func TestIsOpencodeEndpoint(t *testing.T) {
yes := []string{
"https://opencode.ai/zen/v1",
"https://opencode.ai/zen/go/v1",
"https://opencode.ai/zen/v1/chat/completions",
"https://api.opencode.ai/v1",
"https://OPENCODE.AI/zen/v1",
"opencode.ai",
}
for _, e := range yes {
if !isOpencodeEndpoint(e) {
t.Errorf("isOpencodeEndpoint(%q) = false, want true", e)
}
}
no := []string{
"https://api.kilo.ai/api/openrouter",
"http://127.0.0.1:4321/v1",
"https://api.openai.com/v1",
"https://notopencode.ai.example.com/v1",
"https://example.com/opencode.ai",
}
for _, e := range no {
if isOpencodeEndpoint(e) {
t.Errorf("isOpencodeEndpoint(%q) = true, want false", e)
}
}
}
func TestOcRequestID(t *testing.T) {
id := ocRequestID()
if !strings.HasPrefix(id, "msg_") {
t.Fatalf("request id %q does not start with msg_", id)
}
if len(id) != 30 {
t.Fatalf("request id length = %d, want 30: %q", len(id), id)
}
for _, c := range id[4:] {
if !strings.ContainsRune(opencodeIDAlphabet, c) {
t.Fatalf("invalid character %c in request id %q", c, id)
}
}
if ocRequestID() == id {
t.Fatalf("consecutive request ids should differ: %q", id)
}
}
func TestApplyLLMHeadersOpencode(t *testing.T) {
cfg := defCfg
cfg.Endpoint = "https://opencode.ai/zen/v1"
cfg.APIKey = "-"
req, err := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
applyLLMHeaders(req, &cfg, "msg_test")
if got := req.Header.Get("User-Agent"); !strings.Contains(got, "opencode/1.18.31") || !strings.Contains(got, "runtime/bun") {
t.Errorf("user-agent = %q", got)
}
if got := req.Header.Get("x-opencode-client"); got != "cli" {
t.Errorf("x-opencode-client = %q, want cli", got)
}
if got := req.Header.Get("x-opencode-project"); got != projectID() {
t.Errorf("x-opencode-project = %q, want %q", got, projectID())
}
if got := req.Header.Get("x-opencode-session"); got != opencodeSessionID() {
t.Errorf("x-opencode-session = %q, want %q", got, opencodeSessionID())
}
if got := req.Header.Get("x-opencode-request"); got != "msg_test" {
t.Errorf("x-opencode-request = %q, want msg_test", got)
}
if got := req.Header.Get("X-Session-Id"); got != "" {
t.Errorf("legacy X-Session-Id should be absent, got %q", got)
}
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("Authorization should be absent for '-' key, got %q", got)
}
}
func TestApplyLLMHeadersOpencodeAuth(t *testing.T) {
cfg := defCfg
cfg.Endpoint = "https://opencode.ai/zen/v1"
cfg.APIKey = "public"
req, _ := http.NewRequest("POST", "https://opencode.ai/zen/v1/chat/completions", nil)
applyLLMHeaders(req, &cfg, "msg_test")
if got := req.Header.Get("Authorization"); got != "Bearer public" {
t.Errorf("Authorization = %q, want Bearer public", got)
}
}
func TestOpencodeSessionID(t *testing.T) { func TestOpencodeSessionID(t *testing.T) {
sid := opencodeSessionID() sid := opencodeSessionID()
if !strings.HasPrefix(sid, "ses_") { if !strings.HasPrefix(sid, "ses_") {