diff --git a/README.md b/README.md index acfe7b2..8c29c23 100644 --- a/README.md +++ b/README.md @@ -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}`. 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`)**: - - 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. - 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. @@ -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 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: `, `x-opencode-session: `, 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 ` 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`). - 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. diff --git a/main.go b/main.go index 17e1a2b..b53cd4d 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "crypto/md5" + "crypto/rand" "encoding/hex" "encoding/json" "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"}} 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 { 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) if err != nil { return 0 } - req.Header.Set("User-Agent", opencodeAgentVersion) - 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) } + applyLLMHeaders(req, cfg, ocRequestID()) resp, err := client.Do(req) if err != nil || resp.StatusCode >= 400 { return 0 } 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) if err != nil { return "", err } - req.Header.Set("User-Agent", opencodeAgentVersion) - 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) } + applyLLMHeaders(req, cfg, ocRequestID()) resp, err := client.Do(req) if err != nil { return "", err } 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) var resp *http.Response var err error + reqID := ocRequestID() for i := 0; i <= len(fib); i++ { if err := ctx.Err(); err != nil { 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) } req, _ := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.Endpoint, "/")+"/chat/completions", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", opencodeAgentVersion) - 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) } + applyLLMHeaders(req, cfg, reqID) resp, err = client.Do(req) var is4xxClientErr bool if err == nil && resp.StatusCode >= 400 { @@ -1694,6 +1683,57 @@ func opencodeSessionID() string { 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) { sid = strings.TrimSpace(sid) if sid == "" { diff --git a/main_test.go b/main_test.go index c49718d..91cc7d6 100644 --- a/main_test.go +++ b/main_test.go @@ -1147,13 +1147,15 @@ func TestCol(t *testing.T) { // ---------- llm / AL / summarize / compact via httptest (no real network) ---------- 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) { gotPath = r.URL.Path gotAuth = r.Header.Get("Authorization") 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") - 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"}}]}`)) })) defer srv.Close() @@ -1175,17 +1177,111 @@ func TestLLMNonStreamingAndHeaders(t *testing.T) { if !strings.Contains(gotUA, "opencode/1.18.31") { t.Errorf("user-agent = %q", gotUA) } - if gotOCClient != "cli" { - t.Errorf("x-opencode-client = %q, want cli", gotOCClient) + // Non-OpenCode endpoints keep the legacy session-affinity headers and must + // not receive any x-opencode-* headers. + if gotLegacy != opencodeSessionID() { + t.Errorf("X-Session-Id = %q, want %q", gotLegacy, opencodeSessionID()) } - if gotOCSession != opencodeSessionID() { - t.Errorf("x-opencode-session = %q, want %q", gotOCSession, opencodeSessionID()) + if gotAffinity != 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" { 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) { sid := opencodeSessionID() if !strings.HasPrefix(sid, "ses_") {