Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/alino_agent9.json
T

1 line
45 KiB
JSON

{"repo": "alino/agent9", "files": {"src/pi9/internal/provider/openrouter.go": {"3db35d3efef45372b79d3868e862d49b426e7c43": {"t": 1785746060.3435853, "n": 15275, "h": "8b0aced0e8262d06", "c": "// Package provider: this file holds OpenAI-compatible /chat/completions\n// streaming. The wire format is shared by OpenRouter, OpenAI, Groq,\n// DeepSeek, Fireworks, Together, Cerebras, Mistral, and xAI — pi9 reaches\n// all of them through the same code path via openaiCompat.\n//\n// Phase 10: extracted shared types to types.go; this file now exposes\n// both the legacy free function StreamRequest (kept for backward\n// compatibility) and the new Provider-based openaiCompat type.\n//\n// Plan 9 notes:\n// - net/http and crypto/tls work natively on plan9/amd64 (proven by\n// tinyxena). No cgo, no signal-handling weirdness.\n// - Plan 9's Go runtime does NOT honor $SSL_CERT_FILE the way\n// unix-Go does. We read it ourselves and stuff the certs into a\n// custom *x509.CertPool on the http.Transport.\n// - 9front needs Mozilla's CA bundle at /sys/lib/tls/ca.pem (Phase 9b\n// install does this automatically via pi9-install).\npackage provider\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\n// endpointURL is the OpenRouter chat-completions endpoint. Overridable\n// via $OPENROUTER_API_URL for testing against a mock server. This is\n// the LEGACY entry point used by main.go's existing StreamRequest call;\n// the per-provider openaiCompat type chooses its endpoint based on\n// ProviderID.\nfunc endpointURL() string {\n\tif u := os.Getenv(\"OPENROUTER_API_URL\"); u != \"\" {\n\t\treturn u\n\t}\n\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n}\n\n// providerEndpoint returns the chat-completions URL for an\n// OpenAI-compatible provider. Each provider has a stable URL; cfg.APIURL\n// overrides when set (rarely needed outside testing).\nfunc providerEndpoint(id ProviderID, cfg Config) string {\n\tif cfg.APIURL != \"\" {\n\t\treturn cfg.APIURL\n\t}\n\tswitch id {\n\tcase ProviderOpenAI:\n\t\treturn \"https://api.openai.com/v1/chat/completions\"\n\tcase ProviderOpenRouter:\n\t\t// Honor legacy env var so existing setups keep working.\n\t\tif u := os.Getenv(\"OPENROUTER_API_URL\"); u != \"\" {\n\t\t\treturn u\n\t\t}\n\t\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n\tcase ProviderGroq:\n\t\treturn \"https://api.groq.com/openai/v1/chat/completions\"\n\tcase ProviderDeepSeek:\n\t\treturn \"https://api.deepseek.com/v1/chat/completions\"\n\tcase ProviderXAI:\n\t\treturn \"https://api.x.ai/v1/chat/completions\"\n\tcase ProviderMistral:\n\t\treturn \"https://api.mistral.ai/v1/chat/completions\"\n\tcase ProviderTogether:\n\t\treturn \"https://api.together.xyz/v1/chat/completions\"\n\tcase ProviderFireworks:\n\t\treturn \"https://api.fireworks.ai/inference/v1/chat/completions\"\n\tcase ProviderCerebras:\n\t\treturn \"https://api.cerebras.ai/v1/chat/completions\"\n\t}\n\t// Unknown: fall back to OpenRouter (relays to many backends).\n\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n}\n\n// openaiCompat implements Provider for any OpenAI-compatible backend.\n// All it needs is its ProviderID (used to choose endpoint URL and\n// identify itself); the request shape, SSE parser, and tool-call\n// assembly are identical across these providers.\ntype openaiCompat struct {\n\tid ProviderID\n}\n\nfunc (p openaiCompat) Name() ProviderID { return p.id }\n\nfunc (p openaiCompat) Stream(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tcfg.APIURL = providerEndpoint(p.id, cfg)\n\treturn streamOpenAICompat(ctx, cfg, messages)\n}\n\n// httpClient builds an *http.Client with our plan9-aware TLS config.\n// Safe to call per request; the cost is rebuilding the cert pool.\nfunc httpClient() *http.Client {\n\ttlsConf := &tls.Config{}\n\tif cf := os.Getenv(\"SSL_CERT_FILE\"); cf != \"\" {\n\t\tpem, err := os.ReadFile(cf)\n\t\tif err == nil {\n\t\t\tpool := x509.NewCertPool()\n\t\t\tif pool.AppendCertsFromPEM(pem) {\n\t\t\t\ttlsConf.RootCAs = pool\n\t\t\t}\n\t\t}\n\t}\n\tif os.Getenv(\"INSECURE_TLS\") == \"1\" {\n\t\ttlsConf.InsecureSkipVerify = true\n\t}\n\treturn &http.Client{\n\t\t// No outer timeout: streaming responses can be long. We\n\t\t// govern per-chunk timeouts via the read loop instead.\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: tlsConf,\n\t\t\tDisableCompression: true, // SSE comes through unflushed\n\t\t\tResponseHeaderTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// requestBody is the JSON body shape we send to /chat/completions.\n// We build it imperatively so tools can be omitted when nil (some\n// providers reject an empty tools array).\ntype requestBody struct {\n\tModel string `json:\"model\"`\n\tMessages []Message `json:\"messages\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tTools []toolWrapper `json:\"tools,omitempty\"`\n\n\t// ReasoningEffort asks reasoning-capable models how hard to think.\n\t// Mapped from Config.ThinkingLevel via levelToReasoningEffort.\n\t// Omitted (empty) when thinking is off; models that don't support\n\t// it ignore the field.\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\n\t// StreamOptions opts into usage accounting on the final SSE chunk.\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n}\n\n// streamOptions enables include_usage so the provider appends a final\n// chunk carrying token usage. Supported by OpenAI + most compatible\n// backends; ignored by the rest.\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype toolWrapper struct {\n\tType string `json:\"type\"`\n\tFunction Tool `json:\"function\"`\n}\n\n// StreamRequest sends `messages` to the provider with streaming\n// enabled and tool calling configured. The response body is parsed\n// line-by-line; every \"data: …\" SSE event becomes a Chunk on the\n// returned channel. The channel is closed when the stream ends or an\n// error occurs (err returned via errOut).\n//\n// ctx cancellation is honored: closing it disconnects mid-stream and\n// closes the channels.\n//\n// Caller MUST drain the channel; otherwise the goroutine leaks until\n// the HTTP read times out.\n// StreamRequest is the legacy free-function entry point. Kept for\n// backward compatibility with main.go. New code should use\n// provider.Get(id).Stream(...) instead.\n//\n// This dispatches to OpenRouter (matching pre-Phase-10 behavior).\n// The URL is taken from $OPENROUTER_API_URL if set, else\n// openrouter.ai. To use a different provider, call Get directly.\nfunc StreamRequest(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tcfg.APIURL = endpointURL()\n\treturn streamOpenAICompat(ctx, cfg, messages)\n}\n\n// streamOpenAICompat is the internal SSE streaming implementation\n// shared by all OpenAI-compatible providers. Callers set cfg.APIURL\n// to the provider's chat-completions endpoint first.\nfunc streamOpenAICompat(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tchunks := make(chan Chunk, 8)\n\terrs := make(chan error, 1)\n\n\tgo func() {\n\t\tdefer close(chunks)\n\t\tdefer close(errs)\n\n\t\tbody := requestBody{\n\t\t\tModel: cfg.Model,\n\t\t\tMessages: messages,\n\t\t\tMaxTokens: cfg.MaxTokens,\n\t\t\tStream: true,\n\t\t\t// Ask for usage on the terminal chunk. Tolerated by\n\t\t\t// providers that don't support it.\n\t\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\t}\n\t\tif thinkingEnabled(cfg.ThinkingLevel) {\n\t\t\t// Map level -> reasoning_effort; xhigh collapses to high.\n\t\t\t// Empty result means we leave the field omitted.\n\t\t\tbody.ReasoningEffort = levelToReasoningEffort(cfg.ThinkingLevel)\n\t\t}\n\t\tfor _, t := range cfg.Tools {\n\t\t\tbody.Tools = append(body.Tools, toolWrapper{Type: \"function\", Function: t})\n\t\t}\n\t\tbuf, err := json.Marshal(body)\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"marshal: %w\", err)\n\t\t\treturn\n\t\t}\n\t\t// Use the caller-provided per-provider endpoint, NOT endpointURL()\n\t\t// (which is always OpenRouter). openaiCompat.Stream sets cfg.APIURL\n\t\t// via providerEndpoint; legacy StreamRequest sets it to endpointURL().\n\t\t// Hardcoding endpointURL() here sent every non-OpenRouter provider's\n\t\t// request — and its API key in the Bearer header — to OpenRouter.\n\t\treqURL := cfg.APIURL\n\t\tif reqURL == \"\" {\n\t\t\treqURL = endpointURL()\n\t\t}\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", reqURL, bytes.NewReader(buf))\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"new request: %w\", err)\n\t\t\treturn\n\t\t}\n\t\treq.Header.Set(\"authorization\", \"Bearer \"+cfg.APIKey)\n\t\treq.Header.Set(\"content-type\", \"application/json\")\n\t\treq.Header.Set(\"accept\", \"text/event-stream\")\n\t\treq.Header.Set(\"HTTP-Referer\", \"https://github.com/alino/plan9-winxp\")\n\t\treq.Header.Set(\"X-Title\", \"pi9\")\n\n\t\tresp, err := httpClient().Do(req)\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"do: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode >= 400 {\n\t\t\tb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\t\terrs <- fmt.Errorf(\"http %d: %s\", resp.StatusCode, string(b))\n\t\t\treturn\n\t\t}\n\n\t\tif err := readSSE(resp.Body, chunks); err != nil {\n\t\t\terrs <- err\n\t\t\treturn\n\t\t}\n\t}()\n\n\treturn chunks, errs\n}\n\n// sseToolDelta mirrors what OpenAI emits for a streaming tool call\n// chunk. Each delta has an `index` that ties it back to a specific\n// in-flight tool call; we assemble across chunks by index.\ntype sseToolDelta struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\n// sseEvent is the shape of one streamed chat-completions chunk.\n//\n// OpenRouter (and OpenAI) emit:\n//\n//\tdata: {\"id\":\"...\",\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n//\tdata: {\"id\":\"...\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n//\tdata: [DONE]\n//\n// For tool calls:\n//\n//\tdata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\"}}]}}]}\n//\tdata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"/tmp\\\"}\"}}]}}]}\n//\tdata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\ntype sseEvent struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tToolCalls []sseToolDelta `json:\"tool_calls\"`\n\t\t\t// Reasoning is the thinking trace some reasoning models\n\t\t\t// stream alongside content. OpenRouter uses \"reasoning\";\n\t\t\t// a few backends use \"reasoning_content\". Both parsed.\n\t\t\tReasoning string `json:\"reasoning,omitempty\"`\n\t\t\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\t// Usage appears on the final chunk when stream_options.include_usage\n\t// was requested. nil on every other chunk.\n\tUsage *sseUsage `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error,omitempty\"`\n}\n\n// sseUsage is the OpenAI-compatible usage block on the terminal chunk.\ntype sseUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n}\n\n// parseOpenAIUsage converts a wire usage block into the neutral Usage.\n// Returns nil if the block is nil or reports no tokens — pure +\n// testable. TotalTokens is filled from the provider's value, or\n// computed from prompt+completion if the provider omitted it.\nfunc parseOpenAIUsage(u *sseUsage) *Usage {\n\tif u == nil {\n\t\treturn nil\n\t}\n\tif u.PromptTokens == 0 && u.CompletionTokens == 0 && u.TotalTokens == 0 {\n\t\treturn nil\n\t}\n\ttotal := u.TotalTokens\n\tif total == 0 {\n\t\ttotal = u.PromptTokens + u.CompletionTokens\n\t}\n\treturn &Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: total,\n\t}\n}\n\n// readSSE parses the response body as Server-Sent Events and writes\n// Chunks to the channel. Returns nil on clean stream-end.\n//\n// Assembles streaming tool_calls by index into the toolCalls slice;\n// emits the assembled set in the final Done chunk.\nfunc readSSE(r io.Reader, out chan<- Chunk) error {\n\tbr := bufio.NewReaderSize(r, 64*1024)\n\t// In-flight tool calls indexed by their delta index. OpenAI\n\t// streaming guarantees a stable index for each call.\n\ttoolCalls := map[int]*ToolCall{}\n\n\t// Final token usage, if the provider sends it (include_usage). With\n\t// stream_options, OpenAI emits a trailing chunk that has empty\n\t// choices and a usage block AFTER the finish_reason chunk — so we\n\t// stash usage and attach it to the terminal Done chunk rather than\n\t// returning early on finish_reason.\n\tvar usage *Usage\n\n\t// flushTools emits the assembled tool calls in index order.\n\tflushTools := func() []ToolCall {\n\t\tif len(toolCalls) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\t// Determine max index and build ordered slice.\n\t\tmaxIdx := -1\n\t\tfor i := range toolCalls {\n\t\t\tif i > maxIdx {\n\t\t\t\tmaxIdx = i\n\t\t\t}\n\t\t}\n\t\tout := make([]ToolCall, 0, maxIdx+1)\n\t\tfor i := 0; i <= maxIdx; i++ {\n\t\t\ttc, ok := toolCalls[i]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, *tc)\n\t\t}\n\t\treturn out\n\t}\n\n\tfor {\n\t\tline, err := br.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools(), Usage: usage}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"read: %w\", err)\n\t\t}\n\t\tline = strings.TrimRight(line, \"\\r\\n\")\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip \":\" comments (SSE heartbeats)\n\t\tif strings.HasPrefix(line, \":\") {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tpayload := strings.TrimPrefix(line, \"data: \")\n\t\tif payload == \"[DONE]\" {\n\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools(), Usage: usage}\n\t\t\treturn nil\n\t\t}\n\t\tvar ev sseEvent\n\t\tif err := json.Unmarshal([]byte(payload), &ev); err != nil {\n\t\t\t// Skip malformed events; provider sometimes sends\n\t\t\t// non-JSON pings.\n\t\t\tcontinue\n\t\t}\n\t\tif ev.Error != nil {\n\t\t\treturn fmt.Errorf(\"provider: %s — %s\", ev.Error.Type, ev.Error.Message)\n\t\t}\n\t\t// Usage may arrive on a trailing choices-less chunk; capture it\n\t\t// whenever present. Last non-nil value wins.\n\t\tif u := parseOpenAIUsage(ev.Usage); u != nil {\n\t\t\tusage = u\n\t\t}\n\t\tfor _, c := range ev.Choices {\n\t\t\tif c.Delta.Content != \"\" {\n\t\t\t\tout <- Chunk{Delta: c.Delta.Content}\n\t\t\t}\n\t\t\t// Reasoning trace deltas (reasoning models). Prefer\n\t\t\t// \"reasoning\"; fall back to \"reasoning_content\".\n\t\t\tif r := c.Delta.Reasoning; r != \"\" {\n\t\t\t\tout <- Chunk{Reasoning: r}\n\t\t\t} else if r := c.Delta.ReasoningContent; r != \"\" {\n\t\t\t\tout <- Chunk{Reasoning: r}\n\t\t\t}\n\t\t\tfor _, td := range c.Delta.ToolCalls {\n\t\t\t\ttc, ok := toolCalls[td.Index]\n\t\t\t\tif !ok {\n\t\t\t\t\ttc = &ToolCall{}\n\t\t\t\t\ttoolCalls[td.Index] = tc\n\t\t\t\t}\n\t\t\t\tif td.ID != \"\" {\n\t\t\t\t\ttc.ID = td.ID\n\t\t\t\t}\n\t\t\t\tif td.Type != \"\" {\n\t\t\t\t\ttc.Type = td.Type\n\t\t\t\t}\n\t\t\t\tif td.Function.Name != \"\" {\n\t\t\t\t\ttc.Function.Name = td.Function.Name\n\t\t\t\t}\n\t\t\t\tif td.Function.Arguments != \"\" {\n\t\t\t\t\ttc.Function.Arguments += td.Function.Arguments\n\t\t\t\t}\n\t\t\t}\n\t\t\t// finish_reason marks the end of generation, but usage may\n\t\t\t// still arrive in a following choices-less chunk (OpenAI\n\t\t\t// include_usage). Only finalize on [DONE]/EOF unless there\n\t\t\t// are no usage stream-options expectations. We always wait\n\t\t\t// for [DONE]/EOF so usage isn't dropped.\n\t\t\tif c.FinishReason != \"\" {\n\t\t\t\t// Remember we hit a finish; continue reading for a\n\t\t\t\t// possible trailing usage chunk. The stream ends with\n\t\t\t\t// [DONE] or EOF which emits the terminal Done chunk.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n}\n"}, "49b0df42a347f2e1582b7615f92bb619289cf847": {"t": 1785759357.484361, "n": 15275, "h": "8b0aced0e8262d06", "c": "// Package provider: this file holds OpenAI-compatible /chat/completions\n// streaming. The wire format is shared by OpenRouter, OpenAI, Groq,\n// DeepSeek, Fireworks, Together, Cerebras, Mistral, and xAI — pi9 reaches\n// all of them through the same code path via openaiCompat.\n//\n// Phase 10: extracted shared types to types.go; this file now exposes\n// both the legacy free function StreamRequest (kept for backward\n// compatibility) and the new Provider-based openaiCompat type.\n//\n// Plan 9 notes:\n// - net/http and crypto/tls work natively on plan9/amd64 (proven by\n// tinyxena). No cgo, no signal-handling weirdness.\n// - Plan 9's Go runtime does NOT honor $SSL_CERT_FILE the way\n// unix-Go does. We read it ourselves and stuff the certs into a\n// custom *x509.CertPool on the http.Transport.\n// - 9front needs Mozilla's CA bundle at /sys/lib/tls/ca.pem (Phase 9b\n// install does this automatically via pi9-install).\npackage provider\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\n// endpointURL is the OpenRouter chat-completions endpoint. Overridable\n// via $OPENROUTER_API_URL for testing against a mock server. This is\n// the LEGACY entry point used by main.go's existing StreamRequest call;\n// the per-provider openaiCompat type chooses its endpoint based on\n// ProviderID.\nfunc endpointURL() string {\n\tif u := os.Getenv(\"OPENROUTER_API_URL\"); u != \"\" {\n\t\treturn u\n\t}\n\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n}\n\n// providerEndpoint returns the chat-completions URL for an\n// OpenAI-compatible provider. Each provider has a stable URL; cfg.APIURL\n// overrides when set (rarely needed outside testing).\nfunc providerEndpoint(id ProviderID, cfg Config) string {\n\tif cfg.APIURL != \"\" {\n\t\treturn cfg.APIURL\n\t}\n\tswitch id {\n\tcase ProviderOpenAI:\n\t\treturn \"https://api.openai.com/v1/chat/completions\"\n\tcase ProviderOpenRouter:\n\t\t// Honor legacy env var so existing setups keep working.\n\t\tif u := os.Getenv(\"OPENROUTER_API_URL\"); u != \"\" {\n\t\t\treturn u\n\t\t}\n\t\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n\tcase ProviderGroq:\n\t\treturn \"https://api.groq.com/openai/v1/chat/completions\"\n\tcase ProviderDeepSeek:\n\t\treturn \"https://api.deepseek.com/v1/chat/completions\"\n\tcase ProviderXAI:\n\t\treturn \"https://api.x.ai/v1/chat/completions\"\n\tcase ProviderMistral:\n\t\treturn \"https://api.mistral.ai/v1/chat/completions\"\n\tcase ProviderTogether:\n\t\treturn \"https://api.together.xyz/v1/chat/completions\"\n\tcase ProviderFireworks:\n\t\treturn \"https://api.fireworks.ai/inference/v1/chat/completions\"\n\tcase ProviderCerebras:\n\t\treturn \"https://api.cerebras.ai/v1/chat/completions\"\n\t}\n\t// Unknown: fall back to OpenRouter (relays to many backends).\n\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n}\n\n// openaiCompat implements Provider for any OpenAI-compatible backend.\n// All it needs is its ProviderID (used to choose endpoint URL and\n// identify itself); the request shape, SSE parser, and tool-call\n// assembly are identical across these providers.\ntype openaiCompat struct {\n\tid ProviderID\n}\n\nfunc (p openaiCompat) Name() ProviderID { return p.id }\n\nfunc (p openaiCompat) Stream(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tcfg.APIURL = providerEndpoint(p.id, cfg)\n\treturn streamOpenAICompat(ctx, cfg, messages)\n}\n\n// httpClient builds an *http.Client with our plan9-aware TLS config.\n// Safe to call per request; the cost is rebuilding the cert pool.\nfunc httpClient() *http.Client {\n\ttlsConf := &tls.Config{}\n\tif cf := os.Getenv(\"SSL_CERT_FILE\"); cf != \"\" {\n\t\tpem, err := os.ReadFile(cf)\n\t\tif err == nil {\n\t\t\tpool := x509.NewCertPool()\n\t\t\tif pool.AppendCertsFromPEM(pem) {\n\t\t\t\ttlsConf.RootCAs = pool\n\t\t\t}\n\t\t}\n\t}\n\tif os.Getenv(\"INSECURE_TLS\") == \"1\" {\n\t\ttlsConf.InsecureSkipVerify = true\n\t}\n\treturn &http.Client{\n\t\t// No outer timeout: streaming responses can be long. We\n\t\t// govern per-chunk timeouts via the read loop instead.\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: tlsConf,\n\t\t\tDisableCompression: true, // SSE comes through unflushed\n\t\t\tResponseHeaderTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// requestBody is the JSON body shape we send to /chat/completions.\n// We build it imperatively so tools can be omitted when nil (some\n// providers reject an empty tools array).\ntype requestBody struct {\n\tModel string `json:\"model\"`\n\tMessages []Message `json:\"messages\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tTools []toolWrapper `json:\"tools,omitempty\"`\n\n\t// ReasoningEffort asks reasoning-capable models how hard to think.\n\t// Mapped from Config.ThinkingLevel via levelToReasoningEffort.\n\t// Omitted (empty) when thinking is off; models that don't support\n\t// it ignore the field.\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\n\t// StreamOptions opts into usage accounting on the final SSE chunk.\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n}\n\n// streamOptions enables include_usage so the provider appends a final\n// chunk carrying token usage. Supported by OpenAI + most compatible\n// backends; ignored by the rest.\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype toolWrapper struct {\n\tType string `json:\"type\"`\n\tFunction Tool `json:\"function\"`\n}\n\n// StreamRequest sends `messages` to the provider with streaming\n// enabled and tool calling configured. The response body is parsed\n// line-by-line; every \"data: …\" SSE event becomes a Chunk on the\n// returned channel. The channel is closed when the stream ends or an\n// error occurs (err returned via errOut).\n//\n// ctx cancellation is honored: closing it disconnects mid-stream and\n// closes the channels.\n//\n// Caller MUST drain the channel; otherwise the goroutine leaks until\n// the HTTP read times out.\n// StreamRequest is the legacy free-function entry point. Kept for\n// backward compatibility with main.go. New code should use\n// provider.Get(id).Stream(...) instead.\n//\n// This dispatches to OpenRouter (matching pre-Phase-10 behavior).\n// The URL is taken from $OPENROUTER_API_URL if set, else\n// openrouter.ai. To use a different provider, call Get directly.\nfunc StreamRequest(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tcfg.APIURL = endpointURL()\n\treturn streamOpenAICompat(ctx, cfg, messages)\n}\n\n// streamOpenAICompat is the internal SSE streaming implementation\n// shared by all OpenAI-compatible providers. Callers set cfg.APIURL\n// to the provider's chat-completions endpoint first.\nfunc streamOpenAICompat(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tchunks := make(chan Chunk, 8)\n\terrs := make(chan error, 1)\n\n\tgo func() {\n\t\tdefer close(chunks)\n\t\tdefer close(errs)\n\n\t\tbody := requestBody{\n\t\t\tModel: cfg.Model,\n\t\t\tMessages: messages,\n\t\t\tMaxTokens: cfg.MaxTokens,\n\t\t\tStream: true,\n\t\t\t// Ask for usage on the terminal chunk. Tolerated by\n\t\t\t// providers that don't support it.\n\t\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\t}\n\t\tif thinkingEnabled(cfg.ThinkingLevel) {\n\t\t\t// Map level -> reasoning_effort; xhigh collapses to high.\n\t\t\t// Empty result means we leave the field omitted.\n\t\t\tbody.ReasoningEffort = levelToReasoningEffort(cfg.ThinkingLevel)\n\t\t}\n\t\tfor _, t := range cfg.Tools {\n\t\t\tbody.Tools = append(body.Tools, toolWrapper{Type: \"function\", Function: t})\n\t\t}\n\t\tbuf, err := json.Marshal(body)\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"marshal: %w\", err)\n\t\t\treturn\n\t\t}\n\t\t// Use the caller-provided per-provider endpoint, NOT endpointURL()\n\t\t// (which is always OpenRouter). openaiCompat.Stream sets cfg.APIURL\n\t\t// via providerEndpoint; legacy StreamRequest sets it to endpointURL().\n\t\t// Hardcoding endpointURL() here sent every non-OpenRouter provider's\n\t\t// request — and its API key in the Bearer header — to OpenRouter.\n\t\treqURL := cfg.APIURL\n\t\tif reqURL == \"\" {\n\t\t\treqURL = endpointURL()\n\t\t}\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", reqURL, bytes.NewReader(buf))\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"new request: %w\", err)\n\t\t\treturn\n\t\t}\n\t\treq.Header.Set(\"authorization\", \"Bearer \"+cfg.APIKey)\n\t\treq.Header.Set(\"content-type\", \"application/json\")\n\t\treq.Header.Set(\"accept\", \"text/event-stream\")\n\t\treq.Header.Set(\"HTTP-Referer\", \"https://github.com/alino/plan9-winxp\")\n\t\treq.Header.Set(\"X-Title\", \"pi9\")\n\n\t\tresp, err := httpClient().Do(req)\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"do: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode >= 400 {\n\t\t\tb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\t\terrs <- fmt.Errorf(\"http %d: %s\", resp.StatusCode, string(b))\n\t\t\treturn\n\t\t}\n\n\t\tif err := readSSE(resp.Body, chunks); err != nil {\n\t\t\terrs <- err\n\t\t\treturn\n\t\t}\n\t}()\n\n\treturn chunks, errs\n}\n\n// sseToolDelta mirrors what OpenAI emits for a streaming tool call\n// chunk. Each delta has an `index` that ties it back to a specific\n// in-flight tool call; we assemble across chunks by index.\ntype sseToolDelta struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\n// sseEvent is the shape of one streamed chat-completions chunk.\n//\n// OpenRouter (and OpenAI) emit:\n//\n//\tdata: {\"id\":\"...\",\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n//\tdata: {\"id\":\"...\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n//\tdata: [DONE]\n//\n// For tool calls:\n//\n//\tdata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\"}}]}}]}\n//\tdata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"/tmp\\\"}\"}}]}}]}\n//\tdata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\ntype sseEvent struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tToolCalls []sseToolDelta `json:\"tool_calls\"`\n\t\t\t// Reasoning is the thinking trace some reasoning models\n\t\t\t// stream alongside content. OpenRouter uses \"reasoning\";\n\t\t\t// a few backends use \"reasoning_content\". Both parsed.\n\t\t\tReasoning string `json:\"reasoning,omitempty\"`\n\t\t\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\t// Usage appears on the final chunk when stream_options.include_usage\n\t// was requested. nil on every other chunk.\n\tUsage *sseUsage `json:\"usage,omitempty\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error,omitempty\"`\n}\n\n// sseUsage is the OpenAI-compatible usage block on the terminal chunk.\ntype sseUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n}\n\n// parseOpenAIUsage converts a wire usage block into the neutral Usage.\n// Returns nil if the block is nil or reports no tokens — pure +\n// testable. TotalTokens is filled from the provider's value, or\n// computed from prompt+completion if the provider omitted it.\nfunc parseOpenAIUsage(u *sseUsage) *Usage {\n\tif u == nil {\n\t\treturn nil\n\t}\n\tif u.PromptTokens == 0 && u.CompletionTokens == 0 && u.TotalTokens == 0 {\n\t\treturn nil\n\t}\n\ttotal := u.TotalTokens\n\tif total == 0 {\n\t\ttotal = u.PromptTokens + u.CompletionTokens\n\t}\n\treturn &Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: total,\n\t}\n}\n\n// readSSE parses the response body as Server-Sent Events and writes\n// Chunks to the channel. Returns nil on clean stream-end.\n//\n// Assembles streaming tool_calls by index into the toolCalls slice;\n// emits the assembled set in the final Done chunk.\nfunc readSSE(r io.Reader, out chan<- Chunk) error {\n\tbr := bufio.NewReaderSize(r, 64*1024)\n\t// In-flight tool calls indexed by their delta index. OpenAI\n\t// streaming guarantees a stable index for each call.\n\ttoolCalls := map[int]*ToolCall{}\n\n\t// Final token usage, if the provider sends it (include_usage). With\n\t// stream_options, OpenAI emits a trailing chunk that has empty\n\t// choices and a usage block AFTER the finish_reason chunk — so we\n\t// stash usage and attach it to the terminal Done chunk rather than\n\t// returning early on finish_reason.\n\tvar usage *Usage\n\n\t// flushTools emits the assembled tool calls in index order.\n\tflushTools := func() []ToolCall {\n\t\tif len(toolCalls) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\t// Determine max index and build ordered slice.\n\t\tmaxIdx := -1\n\t\tfor i := range toolCalls {\n\t\t\tif i > maxIdx {\n\t\t\t\tmaxIdx = i\n\t\t\t}\n\t\t}\n\t\tout := make([]ToolCall, 0, maxIdx+1)\n\t\tfor i := 0; i <= maxIdx; i++ {\n\t\t\ttc, ok := toolCalls[i]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, *tc)\n\t\t}\n\t\treturn out\n\t}\n\n\tfor {\n\t\tline, err := br.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools(), Usage: usage}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"read: %w\", err)\n\t\t}\n\t\tline = strings.TrimRight(line, \"\\r\\n\")\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip \":\" comments (SSE heartbeats)\n\t\tif strings.HasPrefix(line, \":\") {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tpayload := strings.TrimPrefix(line, \"data: \")\n\t\tif payload == \"[DONE]\" {\n\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools(), Usage: usage}\n\t\t\treturn nil\n\t\t}\n\t\tvar ev sseEvent\n\t\tif err := json.Unmarshal([]byte(payload), &ev); err != nil {\n\t\t\t// Skip malformed events; provider sometimes sends\n\t\t\t// non-JSON pings.\n\t\t\tcontinue\n\t\t}\n\t\tif ev.Error != nil {\n\t\t\treturn fmt.Errorf(\"provider: %s — %s\", ev.Error.Type, ev.Error.Message)\n\t\t}\n\t\t// Usage may arrive on a trailing choices-less chunk; capture it\n\t\t// whenever present. Last non-nil value wins.\n\t\tif u := parseOpenAIUsage(ev.Usage); u != nil {\n\t\t\tusage = u\n\t\t}\n\t\tfor _, c := range ev.Choices {\n\t\t\tif c.Delta.Content != \"\" {\n\t\t\t\tout <- Chunk{Delta: c.Delta.Content}\n\t\t\t}\n\t\t\t// Reasoning trace deltas (reasoning models). Prefer\n\t\t\t// \"reasoning\"; fall back to \"reasoning_content\".\n\t\t\tif r := c.Delta.Reasoning; r != \"\" {\n\t\t\t\tout <- Chunk{Reasoning: r}\n\t\t\t} else if r := c.Delta.ReasoningContent; r != \"\" {\n\t\t\t\tout <- Chunk{Reasoning: r}\n\t\t\t}\n\t\t\tfor _, td := range c.Delta.ToolCalls {\n\t\t\t\ttc, ok := toolCalls[td.Index]\n\t\t\t\tif !ok {\n\t\t\t\t\ttc = &ToolCall{}\n\t\t\t\t\ttoolCalls[td.Index] = tc\n\t\t\t\t}\n\t\t\t\tif td.ID != \"\" {\n\t\t\t\t\ttc.ID = td.ID\n\t\t\t\t}\n\t\t\t\tif td.Type != \"\" {\n\t\t\t\t\ttc.Type = td.Type\n\t\t\t\t}\n\t\t\t\tif td.Function.Name != \"\" {\n\t\t\t\t\ttc.Function.Name = td.Function.Name\n\t\t\t\t}\n\t\t\t\tif td.Function.Arguments != \"\" {\n\t\t\t\t\ttc.Function.Arguments += td.Function.Arguments\n\t\t\t\t}\n\t\t\t}\n\t\t\t// finish_reason marks the end of generation, but usage may\n\t\t\t// still arrive in a following choices-less chunk (OpenAI\n\t\t\t// include_usage). Only finalize on [DONE]/EOF unless there\n\t\t\t// are no usage stream-options expectations. We always wait\n\t\t\t// for [DONE]/EOF so usage isn't dropped.\n\t\t\tif c.FinishReason != \"\" {\n\t\t\t\t// Remember we hit a finish; continue reading for a\n\t\t\t\t// possible trailing usage chunk. The stream ends with\n\t\t\t\t// [DONE] or EOF which emits the terminal Done chunk.\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t}\n}\n"}, "786533862c98e07edd6a6cd3f66f834f7c39fbd1": {"t": 1785759359.2573395, "n": 11246, "h": "2a5152adaf079262", "c": "// Package provider: this file holds OpenAI-compatible /chat/completions\n// streaming. The wire format is shared by OpenRouter, OpenAI, Groq,\n// DeepSeek, Fireworks, Together, Cerebras, Mistral, and xAI — pi9 reaches\n// all of them through the same code path via openaiCompat.\n//\n// Phase 10: extracted shared types to types.go; this file now exposes\n// both the legacy free function StreamRequest (kept for backward\n// compatibility) and the new Provider-based openaiCompat type.\n//\n// Plan 9 notes:\n// - net/http and crypto/tls work natively on plan9/amd64 (proven by\n// tinyxena). No cgo, no signal-handling weirdness.\n// - Plan 9's Go runtime does NOT honor $SSL_CERT_FILE the way\n// unix-Go does. We read it ourselves and stuff the certs into a\n// custom *x509.CertPool on the http.Transport.\n// - 9front needs Mozilla's CA bundle at /sys/lib/tls/ca.pem (Phase 9b\n// install does this automatically via pi9-install).\npackage provider\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"time\"\n)\n\n// endpointURL is the OpenRouter chat-completions endpoint. Overridable\n// via $OPENROUTER_API_URL for testing against a mock server. This is\n// the LEGACY entry point used by main.go's existing StreamRequest call;\n// the per-provider openaiCompat type chooses its endpoint based on\n// ProviderID.\nfunc endpointURL() string {\n\tif u := os.Getenv(\"OPENROUTER_API_URL\"); u != \"\" {\n\t\treturn u\n\t}\n\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n}\n\n// providerEndpoint returns the chat-completions URL for an\n// OpenAI-compatible provider. Each provider has a stable URL; cfg.APIURL\n// overrides when set (rarely needed outside testing).\nfunc providerEndpoint(id ProviderID, cfg Config) string {\n\tif cfg.APIURL != \"\" {\n\t\treturn cfg.APIURL\n\t}\n\tswitch id {\n\tcase ProviderOpenAI:\n\t\treturn \"https://api.openai.com/v1/chat/completions\"\n\tcase ProviderOpenRouter:\n\t\t// Honor legacy env var so existing setups keep working.\n\t\tif u := os.Getenv(\"OPENROUTER_API_URL\"); u != \"\" {\n\t\t\treturn u\n\t\t}\n\t\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n\tcase ProviderGroq:\n\t\treturn \"https://api.groq.com/openai/v1/chat/completions\"\n\tcase ProviderDeepSeek:\n\t\treturn \"https://api.deepseek.com/v1/chat/completions\"\n\tcase ProviderXAI:\n\t\treturn \"https://api.x.ai/v1/chat/completions\"\n\tcase ProviderMistral:\n\t\treturn \"https://api.mistral.ai/v1/chat/completions\"\n\tcase ProviderTogether:\n\t\treturn \"https://api.together.xyz/v1/chat/completions\"\n\tcase ProviderFireworks:\n\t\treturn \"https://api.fireworks.ai/inference/v1/chat/completions\"\n\tcase ProviderCerebras:\n\t\treturn \"https://api.cerebras.ai/v1/chat/completions\"\n\t}\n\t// Unknown: fall back to OpenRouter (relays to many backends).\n\treturn \"https://openrouter.ai/api/v1/chat/completions\"\n}\n\n// openaiCompat implements Provider for any OpenAI-compatible backend.\n// All it needs is its ProviderID (used to choose endpoint URL and\n// identify itself); the request shape, SSE parser, and tool-call\n// assembly are identical across these providers.\ntype openaiCompat struct {\n\tid ProviderID\n}\n\nfunc (p openaiCompat) Name() ProviderID { return p.id }\n\nfunc (p openaiCompat) Stream(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tcfg.APIURL = providerEndpoint(p.id, cfg)\n\treturn streamOpenAICompat(ctx, cfg, messages)\n}\n\n// httpClient builds an *http.Client with our plan9-aware TLS config.\n// Safe to call per request; the cost is rebuilding the cert pool.\nfunc httpClient() *http.Client {\n\ttlsConf := &tls.Config{}\n\tif cf := os.Getenv(\"SSL_CERT_FILE\"); cf != \"\" {\n\t\tpem, err := os.ReadFile(cf)\n\t\tif err == nil {\n\t\t\tpool := x509.NewCertPool()\n\t\t\tif pool.AppendCertsFromPEM(pem) {\n\t\t\t\ttlsConf.RootCAs = pool\n\t\t\t}\n\t\t}\n\t}\n\tif os.Getenv(\"INSECURE_TLS\") == \"1\" {\n\t\ttlsConf.InsecureSkipVerify = true\n\t}\n\treturn &http.Client{\n\t\t// No outer timeout: streaming responses can be long. We\n\t\t// govern per-chunk timeouts via the read loop instead.\n\t\tTransport: &http.Transport{\n\t\t\tTLSClientConfig: tlsConf,\n\t\t\tDisableCompression: true, // SSE comes through unflushed\n\t\t\tResponseHeaderTimeout: 30 * time.Second,\n\t\t},\n\t}\n}\n\n// requestBody is the JSON body shape we send to /chat/completions.\n// We build it imperatively so tools can be omitted when nil (some\n// providers reject an empty tools array).\ntype requestBody struct {\n\tModel string `json:\"model\"`\n\tMessages []Message `json:\"messages\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tTools []toolWrapper `json:\"tools,omitempty\"`\n}\n\ntype toolWrapper struct {\n\tType string `json:\"type\"`\n\tFunction Tool `json:\"function\"`\n}\n\n// StreamRequest sends `messages` to the provider with streaming\n// enabled and tool calling configured. The response body is parsed\n// line-by-line; every \"data: …\" SSE event becomes a Chunk on the\n// returned channel. The channel is closed when the stream ends or an\n// error occurs (err returned via errOut).\n//\n// ctx cancellation is honored: closing it disconnects mid-stream and\n// closes the channels.\n//\n// Caller MUST drain the channel; otherwise the goroutine leaks until\n// the HTTP read times out.\n// StreamRequest is the legacy free-function entry point. Kept for\n// backward compatibility with main.go. New code should use\n// provider.Get(id).Stream(...) instead.\n//\n// This dispatches to OpenRouter (matching pre-Phase-10 behavior).\n// The URL is taken from $OPENROUTER_API_URL if set, else\n// openrouter.ai. To use a different provider, call Get directly.\nfunc StreamRequest(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tcfg.APIURL = endpointURL()\n\treturn streamOpenAICompat(ctx, cfg, messages)\n}\n\n// streamOpenAICompat is the internal SSE streaming implementation\n// shared by all OpenAI-compatible providers. Callers set cfg.APIURL\n// to the provider's chat-completions endpoint first.\nfunc streamOpenAICompat(ctx context.Context, cfg Config, messages []Message) (<-chan Chunk, <-chan error) {\n\tchunks := make(chan Chunk, 8)\n\terrs := make(chan error, 1)\n\n\tgo func() {\n\t\tdefer close(chunks)\n\t\tdefer close(errs)\n\n\t\tbody := requestBody{\n\t\t\tModel: cfg.Model,\n\t\t\tMessages: messages,\n\t\t\tMaxTokens: cfg.MaxTokens,\n\t\t\tStream: true,\n\t\t}\n\t\tfor _, t := range cfg.Tools {\n\t\t\tbody.Tools = append(body.Tools, toolWrapper{Type: \"function\", Function: t})\n\t\t}\n\t\tbuf, err := json.Marshal(body)\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"marshal: %w\", err)\n\t\t\treturn\n\t\t}\n\t\treq, err := http.NewRequestWithContext(ctx, \"POST\", endpointURL(), bytes.NewReader(buf))\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"new request: %w\", err)\n\t\t\treturn\n\t\t}\n\t\treq.Header.Set(\"authorization\", \"Bearer \"+cfg.APIKey)\n\t\treq.Header.Set(\"content-type\", \"application/json\")\n\t\treq.Header.Set(\"accept\", \"text/event-stream\")\n\t\treq.Header.Set(\"HTTP-Referer\", \"https://github.com/alino/plan9-winxp\")\n\t\treq.Header.Set(\"X-Title\", \"pi9\")\n\n\t\tresp, err := httpClient().Do(req)\n\t\tif err != nil {\n\t\t\terrs <- fmt.Errorf(\"do: %w\", err)\n\t\t\treturn\n\t\t}\n\t\tdefer resp.Body.Close()\n\n\t\tif resp.StatusCode >= 400 {\n\t\t\tb, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\t\terrs <- fmt.Errorf(\"http %d: %s\", resp.StatusCode, string(b))\n\t\t\treturn\n\t\t}\n\n\t\tif err := readSSE(resp.Body, chunks); err != nil {\n\t\t\terrs <- err\n\t\t\treturn\n\t\t}\n\t}()\n\n\treturn chunks, errs\n}\n\n// sseToolDelta mirrors what OpenAI emits for a streaming tool call\n// chunk. Each delta has an `index` that ties it back to a specific\n// in-flight tool call; we assemble across chunks by index.\ntype sseToolDelta struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\n// sseEvent is the shape of one streamed chat-completions chunk.\n//\n// OpenRouter (and OpenAI) emit:\n//\n//\tdata: {\"id\":\"...\",\"choices\":[{\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}]}\n//\tdata: {\"id\":\"...\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n//\tdata: [DONE]\n//\n// For tool calls:\n//\n//\tdata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_abc\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"\"}}]}}]}\n//\tdata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"path\\\":\\\"/tmp\\\"}\"}}]}}]}\n//\tdata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\ntype sseEvent struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tToolCalls []sseToolDelta `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t\tType string `json:\"type\"`\n\t} `json:\"error,omitempty\"`\n}\n\n// readSSE parses the response body as Server-Sent Events and writes\n// Chunks to the channel. Returns nil on clean stream-end.\n//\n// Assembles streaming tool_calls by index into the toolCalls slice;\n// emits the assembled set in the final Done chunk.\nfunc readSSE(r io.Reader, out chan<- Chunk) error {\n\tbr := bufio.NewReaderSize(r, 64*1024)\n\t// In-flight tool calls indexed by their delta index. OpenAI\n\t// streaming guarantees a stable index for each call.\n\ttoolCalls := map[int]*ToolCall{}\n\n\t// flushTools emits the assembled tool calls in index order.\n\tflushTools := func() []ToolCall {\n\t\tif len(toolCalls) == 0 {\n\t\t\treturn nil\n\t\t}\n\t\t// Determine max index and build ordered slice.\n\t\tmaxIdx := -1\n\t\tfor i := range toolCalls {\n\t\t\tif i > maxIdx {\n\t\t\t\tmaxIdx = i\n\t\t\t}\n\t\t}\n\t\tout := make([]ToolCall, 0, maxIdx+1)\n\t\tfor i := 0; i <= maxIdx; i++ {\n\t\t\ttc, ok := toolCalls[i]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tout = append(out, *tc)\n\t\t}\n\t\treturn out\n\t}\n\n\tfor {\n\t\tline, err := br.ReadString('\\n')\n\t\tif err != nil {\n\t\t\tif err == io.EOF {\n\t\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools()}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\treturn fmt.Errorf(\"read: %w\", err)\n\t\t}\n\t\tline = strings.TrimRight(line, \"\\r\\n\")\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\t// Skip \":\" comments (SSE heartbeats)\n\t\tif strings.HasPrefix(line, \":\") {\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.HasPrefix(line, \"data: \") {\n\t\t\tcontinue\n\t\t}\n\t\tpayload := strings.TrimPrefix(line, \"data: \")\n\t\tif payload == \"[DONE]\" {\n\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools()}\n\t\t\treturn nil\n\t\t}\n\t\tvar ev sseEvent\n\t\tif err := json.Unmarshal([]byte(payload), &ev); err != nil {\n\t\t\t// Skip malformed events; provider sometimes sends\n\t\t\t// non-JSON pings.\n\t\t\tcontinue\n\t\t}\n\t\tif ev.Error != nil {\n\t\t\treturn fmt.Errorf(\"provider: %s — %s\", ev.Error.Type, ev.Error.Message)\n\t\t}\n\t\tfor _, c := range ev.Choices {\n\t\t\tif c.Delta.Content != \"\" {\n\t\t\t\tout <- Chunk{Delta: c.Delta.Content}\n\t\t\t}\n\t\t\tfor _, td := range c.Delta.ToolCalls {\n\t\t\t\ttc, ok := toolCalls[td.Index]\n\t\t\t\tif !ok {\n\t\t\t\t\ttc = &ToolCall{}\n\t\t\t\t\ttoolCalls[td.Index] = tc\n\t\t\t\t}\n\t\t\t\tif td.ID != \"\" {\n\t\t\t\t\ttc.ID = td.ID\n\t\t\t\t}\n\t\t\t\tif td.Type != \"\" {\n\t\t\t\t\ttc.Type = td.Type\n\t\t\t\t}\n\t\t\t\tif td.Function.Name != \"\" {\n\t\t\t\t\ttc.Function.Name = td.Function.Name\n\t\t\t\t}\n\t\t\t\tif td.Function.Arguments != \"\" {\n\t\t\t\t\ttc.Function.Arguments += td.Function.Arguments\n\t\t\t\t}\n\t\t\t}\n\t\t\tif c.FinishReason != \"\" {\n\t\t\t\tout <- Chunk{Done: true, ToolCalls: flushTools()}\n\t\t\t\treturn nil\n\t\t\t}\n\t\t}\n\t}\n}\n"}}}}