more oc fixes
This commit is contained in:
@@ -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: <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`).
|
||||
- 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.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
+102
-6
@@ -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_") {
|
||||
|
||||
Reference in New Issue
Block a user