diff --git a/Dockerfile b/Dockerfile
index 51717e04..27566f09 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,7 @@ FROM ${DOCKER_HUB}/alpine:3.23
ENV UID=1337 \
GID=1337
-RUN apk add --no-cache su-exec ca-certificates bash jq curl yq-go
+RUN apk add --no-cache su-exec ca-certificates bash jq curl yq-go tzdata
COPY --from=builder /build/ai /usr/bin/ai
COPY ./docker-run.sh /docker-run.sh
diff --git a/README.md b/README.md
index e7ac8aee..95122582 100644
--- a/README.md
+++ b/README.md
@@ -301,7 +301,7 @@ Content is the universal `ContentBlock` (`text` / `thinking` / `toolCall` / `ima
**Same protocol, new vendor** — usually *no code*:
-1. Add a model entry (in the generated catalog or a hand-built `ai.Model`) with the right `API`, `Provider`, `BaseURL`.
+1. Add a model entry in ai-services, or configure a custom provider model with the right `API`, `Provider`, `BaseURL`.
2. Map the provider to its API-key env var(s) in `pkg/ai/env_api_keys.go`.
3. Add any `Compat` overrides (most base-URL patterns are auto-detected by `detectOpenAICompletionsCompat`).
@@ -317,19 +317,15 @@ Provider-specific behaviors worth knowing live in `pkg/ai/providers`: OpenAI *Co
## The model catalog
-The runtime catalog is `Models` (`map[Provider]map[string]Model`) loaded from a large JSON literal in `pkg/ai/models_generated.go`. Accessors: `GetModel`, `GetProviders`, `GetModels`.
+The model catalog is owned by ai-services. The bridge loads `/models?feature=bridge:ai`, applies each model's runtime metadata, and fails provider resolution if ai-services does not return a catalog. There is no bridge-generated fallback catalog.
-It is **generated** by `cmd/generate-models-go`:
+Custom providers use the same ai-services catalog for model metadata. The bridge filters that catalog to the supported provider runtime (`openai`, `openrouter`, `anthropic`, or `google-vertex`) and then uses the user's configured base URL and API key for execution. Arbitrary model IDs and generic OpenAI-compatible providers are not accepted.
-```sh
-go run ./cmd/generate-models-go [output-path] [--include-unregistered]
-```
-
-It fetches `models.dev` and `openrouter.ai`, keeps only **tool-capable** models, normalizes capabilities/pricing, and applies hand-maintained overrides (`pkg/ai/modelcatalog/`) — e.g. `ThinkingLevelMap` for gpt-5/Gemini-3, Anthropic-style cache-control for OpenRouter Anthropic models. Reasoning levels form a ladder `off < minimal < low < medium < high < xhigh`; `ClampThinkingLevel` snaps a request to the nearest supported level.
+Reasoning levels form a ladder `off < minimal < low < medium < high < xhigh`; `ClampThinkingLevel` snaps a request to the nearest supported level from the model metadata the bridge was given.
## Image generation
-Image generation is a **separate path** (`pkg/ai/images.go`, `images_*.go`): `ai.GenerateImages(ctx, ImagesModel, ImagesContext, ImagesOptions) AssistantImages` (synchronous, no streaming). It has its own model catalog (`image_models_generated.go` — FLUX.2, Seedream, Gemini "Nano Banana", GPT Image, Recraft, etc.) and its own registry. The built-in implementation routes through OpenRouter; blank-import `pkg/ai/providers/images` to enable it. Models can also expose **provider-native** `image_generation` as a built-in tool (see [chat tools](#built-in-chat-tools--adding-your-own)).
+Image generation is a **separate path** (`pkg/ai/images.go`, `images_*.go`): `ai.GenerateImages(ctx, ImagesModel, ImagesContext, ImagesOptions) AssistantImages` (synchronous, no streaming). Image model metadata must come from ai-services or explicit provider configuration; the bridge does not keep a generated image catalog. The built-in implementation supports OpenRouter; blank-import `pkg/ai/providers/images` to enable it. Models can also expose **provider-native** `image_generation` as a built-in tool (see [chat tools](#built-in-chat-tools--adding-your-own)).
## The agent runtime
@@ -374,11 +370,13 @@ Errors are typed with codes (`pkg/agent/harness/public_errors.go`): `CompactionE
| Tool | Purpose | Notes |
|------|---------|-------|
-| `get_session` | Live chat metadata (current time/timezone, model, reasoning, disabled tools, attachments) | read-only; recomputes time per call |
-| `fetch` | Fetch an HTTP/HTTPS URL → readable text + metadata | direct fetch (≤2 MiB, ≤20 000 chars) or Exa-backed contents (≤10 000 chars) with fallback |
-| `web_search` | Web search via Exa | only enabled for the Beeper provider with a proxy token; rich Exa options; results become source citations |
+| `get_session` | Live chat metadata (current time/timezone, model, reasoning, search/fetch modes, attachments) | read-only; recomputes time per call |
+| `fetch` | Fetch a full HTTP/HTTPS URL → readable text + metadata | Beeper mode: direct fetch (≤2 MiB, ≤20 000 chars) or AI-services `/tools/fetch` extraction with fallback; native mode: provider URL-context/fetch tool when available |
+| `web_search` | Web search | Exa-backed Beeper search, enabled when room search mode is `beeper`; returns concise URL results for optional follow-up `fetch` calls |
+
+Tools are gated per-room via the `com.beeper.ai.tools` state event. `search` may be `off`, `beeper`, or `native`; `fetch` may be `off`, `beeper`, or `native`. The legacy `disabled` array is still read for older room state. In `beeper` mode, web tools route through AI-services (`/tools/web_search`, `/tools/fetch`) using the appservice bearer token. In `native` mode, provider-native tools are injected where supported: OpenAI/OpenRouter search, OpenRouter fetch, Anthropic search/fetch, and Google search/URL context. If the selected provider API has no native equivalent, that native tool is unavailable. Search result URLs stay in the tool view; fetched pages, provider-native citation annotations, URL-context metadata, and final-answer URLs become canonical `com.beeper.source` artifacts for client source cards. Other provider-native built-ins, such as `image_generation`, are still injected from the model catalog (`pkg/connector/builtin_tools.go`).
-Tools are gated per-room via the `com.beeper.ai.tools` state event's `disabled` array. Exa-backed tools route through the AI-services proxy (`/proxy/exa/v1/...`) using the appservice bearer token. Some models additionally expose **provider-native** built-ins (`image_generation`, `web_search`) injected into the request payload (`pkg/connector/builtin_tools.go`).
+`fetch` tries the URL directly first with `Accept` preferring Markdown, plain text, JSON, XML, and CSV. If the response is already agent-readable (Markdown/plain/JSON/XML/CSV/source-ish), it returns that result without backend extraction. If the response is HTML, it checks HTTP `Link` headers and HTML `` for a readable alternate and fetches that directly. Only when the direct representation is not agent-ready does it call AI-services `/tools/fetch`. Local/private hosts, GitHub raw/gist URLs, GitLab-style raw paths, and source/text file extensions are treated as direct-fetch candidates.
**Adding a tool:**
@@ -386,9 +384,9 @@ Tools are gated per-room via the `com.beeper.ai.tools` state event's `disabled`
2. Return `jsonResult(value)` for consistent text + `Details` output.
3. Register in `chattools.Tools` (unconditionally or behind a config gate).
4. Wire config in `pkg/connector/chat_tools.go` and honor `DisabledTools`.
-5. If it produces citable sources, mirror `webSearchSourceParts` so URLs surface as message sources.
+5. If it produces citable sources, add canonical source observations in `pkg/connector/sources.go` so URLs surface as message sources.
-> **Security note:** `fetch` has **no SSRF guard** — it can reach localhost/private/link-local addresses (it just bypasses Exa for them). Treat it accordingly in your threat model.
+> **Security note:** the direct fetch path intentionally bypasses AI-services for localhost/private/link-local addresses, raw asset URLs, and source-like files, and has **no SSRF guard**. Deployments that cannot allow bridge-origin egress to private networks should enforce an upstream deny-list or network policy before enabling `fetch`.
## Sessions: the branching conversation tree
@@ -414,8 +412,8 @@ The bridge advertises five login flows (`pkg/connector/login.go`):
| Flow | What it does |
|------|--------------|
-| `beeper` | The default **Beeper AI** login. Routes through an `ai-services.` proxy derived from the user's homeserver; uses an appservice bearer token, no stored key. Read-only/managed. |
-| `openai-responses` / `openai-completions` / `openai-codex-responses` | **Custom provider**: enter base URL + API key, the bridge fetches `/models`, you pick a default model. |
+| `beeper` | The default **Beeper AI** login. Loads its catalog and runtime proxy metadata from `ai-services.` derived from the user's homeserver; uses an appservice bearer token, no stored key. Read-only/managed. |
+| `openai-responses` / `openai-completions` / `openai-codex-responses` / `anthropic-messages` / `google-vertex` | **Custom provider**: enter base URL + API key, the bridge loads matching model metadata from ai-services, then you pick a default model. |
| `chatgpt-device` | **ChatGPT** OAuth device-code flow (PKCE). Stores access + refresh tokens, auto-refreshes within 2 min of expiry. |
One Matrix user can hold multiple AI logins; there's a canonical "AI Chats" login per user. Provider configs (with secrets) live in `UserLoginMetadata.Providers`. API keys support `env:NAME` indirection. The `beeper` provider is special and **read-only** — it can't be added/updated/deleted.
@@ -464,7 +462,7 @@ compaction:
Three scopes layer together: **bridge-wide** YAML → **per-login** provider configs (`UserLoginMetadata.Providers`) → **per-room** state (`com.beeper.ai.model` / `.additional_prompt` / `.tools`).
-Relevant constants: default Beeper model `beeper/default`, title-generation model `gpt-4.1-mini` (fallback `gpt-5-mini`), default AI-services proxy path `/proxy/openai/v1`.
+Relevant constants: default Beeper model `beeper/default`, title-generation model `gpt-4.1-mini` (fallback `gpt-5-mini`), default AI Services base URL derived from the user's homeserver domain.
---
@@ -520,7 +518,7 @@ It serves `/v1/models`, `/v1/responses`, `/v1/chat/completions`, and `/api/strea
- **Reasoning is double-validated and clamped** — setting a model can silently change the effective reasoning level.
- **Two parallel session-tree implementations** (`aidb` vs `session` SQLite files) with near-duplicate SQL and one subtle difference (`ON DELETE CASCADE`).
- **Token counts are estimates** (≈ chars/4) — compaction thresholds are approximate.
-- **`fetch` has no SSRF protection.**
+- **The direct fetch path intentionally allows private-network/raw-asset egress and has no SSRF protection.**
- **`ProviderConfig` holds secrets** (API keys, refresh tokens) in login metadata and serializes to JSON *and* YAML — don't log it.
- **AG-UI `Event` is a map, not a struct** — read typed fields via `Get`/`String`; unknown fields survive round-trips.
@@ -531,8 +529,7 @@ It serves `/v1/models`, `/v1/responses`, `/v1/chat/completions`, and `/api/strea
| Package | Responsibility |
|---------|----------------|
| `cmd/ai` | bridge entry point (registers connector + providers) |
-| `cmd/generate-models-go` | regenerates the text-model catalog from upstream sources |
-| `pkg/ai` | provider/API/model abstraction, streaming interface, model catalog, env keys |
+| `pkg/ai` | provider/API/model abstraction, streaming interface, env keys |
| `pkg/ai/providers` | built-in provider implementations (OpenAI Completions/Responses/Codex, Anthropic, Google GenAI/Vertex) + image generation |
| `pkg/ai-stream` | the `Run` model: AG-UI event accumulation, anchor/stream/final projection, approvals, final-payload sizing |
| `pkg/ag-ui` | the AG-UI wire event protocol, typed events, schema, validation, capabilities |
@@ -541,7 +538,7 @@ It serves `/v1/models`, `/v1/responses`, `/v1/chat/completions`, and `/api/strea
| `pkg/agent/harness/session` | branching conversation tree (per-conversation SQLite) |
| `pkg/agent/autocompact` | compaction trigger policy |
| `pkg/chattools` | built-in tools: `get_session`, `fetch`, `web_search` |
-| `pkg/connector` | the `bridgev2` connector: rooms↔sessions, slash/bridge commands, login, provider routes, capabilities, contacts, direct media, room state |
+| `pkg/connector` | the `bridgev2` connector: rooms↔sessions, slash/bridge commands, login, provider catalog loading, capabilities, contacts, direct media, room state |
| `pkg/msgconv` | Matrix ⇄ AI message conversion |
| `pkg/aiid` | deterministic IDs + metadata types |
| `pkg/aidb` | bridge-DB persistence: session storage + active-stream resume |
diff --git a/cmd/generate-models-go/main.go b/cmd/generate-models-go/main.go
deleted file mode 100644
index 433d75cd..00000000
--- a/cmd/generate-models-go/main.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- "github.com/beeper/ai-bridge/pkg/ai"
- "github.com/beeper/ai-bridge/pkg/ai/modelcatalog"
-)
-
-func main() {
- outputPath, includeUnregistered, err := parseArgs(os.Args[1:])
- if err != nil {
- fmt.Fprintln(os.Stderr, err)
- os.Exit(2)
- }
-
- catalog, err := modelcatalog.Build(context.Background(), modelcatalog.Options{
- IncludeUnregistered: includeUnregistered,
- })
- if err != nil {
- fmt.Fprintf(os.Stderr, "generate models: %v\n", err)
- os.Exit(1)
- }
- source, err := modelcatalog.GenerateGoSource(catalog, "ai")
- if err != nil {
- fmt.Fprintf(os.Stderr, "generate Go source: %v\n", err)
- os.Exit(1)
- }
- if err := os.WriteFile(outputPath, source, 0o644); err != nil {
- fmt.Fprintf(os.Stderr, "write %s: %v\n", outputPath, err)
- os.Exit(1)
- }
-
- if len(catalog.Skipped) > 0 && !includeUnregistered {
- fmt.Fprintf(os.Stderr, "Skipped providers with unregistered APIs: %s. Use --include-unregistered after porting providers.\n", providerList(catalog.Skipped))
- }
- fmt.Printf("Wrote %d models across %d providers to %s\n", catalog.Count(), len(catalog.ProviderOrder), outputPath)
-}
-
-func parseArgs(args []string) (string, bool, error) {
- outputPath := "pkg/ai/models_generated.go"
- includeUnregistered := false
- outputSet := false
- for _, arg := range args {
- switch arg {
- case "--include-unregistered":
- includeUnregistered = true
- case "-h", "--help":
- return "", false, fmt.Errorf("usage: generate-models-go [output-path] [--include-unregistered]")
- default:
- if strings.HasPrefix(arg, "-") {
- return "", false, fmt.Errorf("unknown flag %s", arg)
- }
- if outputSet {
- return "", false, fmt.Errorf("unexpected argument %s", arg)
- }
- outputPath = arg
- outputSet = true
- }
- }
- return outputPath, includeUnregistered, nil
-}
-
-func providerList(providers []ai.Provider) string {
- parts := make([]string, 0, len(providers))
- for _, provider := range providers {
- parts = append(parts, string(provider))
- }
- return strings.Join(parts, ", ")
-}
diff --git a/cmd/generate-models-go/main_test.go b/cmd/generate-models-go/main_test.go
deleted file mode 100644
index 445154ba..00000000
--- a/cmd/generate-models-go/main_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package main
-
-import "testing"
-
-func TestParseArgsAcceptsIncludeUnregisteredBeforeOrAfterOutput(t *testing.T) {
- for _, args := range [][]string{
- {"--include-unregistered", "/tmp/models.go"},
- {"/tmp/models.go", "--include-unregistered"},
- } {
- outputPath, includeUnregistered, err := parseArgs(args)
- if err != nil {
- t.Fatalf("parseArgs(%v): %v", args, err)
- }
- if outputPath != "/tmp/models.go" {
- t.Fatalf("parseArgs(%v) output = %q", args, outputPath)
- }
- if !includeUnregistered {
- t.Fatalf("parseArgs(%v) did not enable includeUnregistered", args)
- }
- }
-}
diff --git a/pkg/agent/agent_loop.go b/pkg/agent/agent_loop.go
index bdf79a88..eee83945 100644
--- a/pkg/agent/agent_loop.go
+++ b/pkg/agent/agent_loop.go
@@ -595,7 +595,14 @@ func hasSequentialToolCall(currentContext *AgentContext, toolCalls []AgentToolCa
}
func createErrorToolResult(message string) AgentToolResult[any] {
- return AgentToolResult[any]{Content: []ai.ContentBlock{{Type: "text", Text: message}}, Details: map[string]any{}}
+ return AgentToolResult[any]{
+ Content: []ai.ContentBlock{{Type: "text", Text: message}},
+ Details: map[string]any{
+ "state": "error",
+ "status": "failed",
+ "reason": message,
+ },
+ }
}
func shouldTerminateToolBatch(results []AgentToolResult[any]) bool {
diff --git a/pkg/agent/agent_loop_test.go b/pkg/agent/agent_loop_test.go
index 9b7e61b5..87597c32 100644
--- a/pkg/agent/agent_loop_test.go
+++ b/pkg/agent/agent_loop_test.go
@@ -328,6 +328,10 @@ func TestRunAgentLoopTurnsToolUpdateEmitErrorIntoToolResult(t *testing.T) {
if content[0].Text != "update emit failed" {
t.Fatalf("expected update emit error content, got %#v", content)
}
+ details, _ := messages[2].Details.(map[string]any)
+ if details["state"] != "error" || details["status"] != "failed" || details["reason"] != "update emit failed" {
+ t.Fatalf("expected structured tool error details, got %#v", messages[2].Details)
+ }
}
func TestRunAgentLoopValidatesPreparedToolArguments(t *testing.T) {
diff --git a/pkg/ai-stream/message.go b/pkg/ai-stream/message.go
index 9fd7dc4c..61554126 100644
--- a/pkg/ai-stream/message.go
+++ b/pkg/ai-stream/message.go
@@ -172,6 +172,13 @@ func (t Run) Messages(includeReasoning bool) []agui.Message {
messageID, _ := evt.Get("messageId").(string)
message := ensureReasoningMessage(messageID)
message.content.WriteString(asString(evt.Get("delta")))
+ case agui.EventReasoningMsgChunk:
+ if !includeReasoning {
+ continue
+ }
+ messageID, _ := evt.Get("messageId").(string)
+ message := ensureReasoningMessage(messageID)
+ message.content.WriteString(asString(evt.Get("delta")))
case agui.EventReasoningMsgEnd:
messageID, _ := evt.Get("messageId").(string)
closeReasoningMessage(messageID)
@@ -305,6 +312,8 @@ func (t Run) FinalBeeperAIMessage(textBudget int, includeThinking bool) UIMessag
thinkingParts := map[string]*projectedPart{}
toolParts := map[string]MessagePart{}
stepParts := map[string]MessagePart{}
+ activityParts := map[string]MessagePart{}
+ activityContents := map[string]map[string]any{}
sourceParts := map[string]MessagePart{}
currentTextMessageID := ""
openTextMessageID := ""
@@ -452,6 +461,16 @@ func (t Run) FinalBeeperAIMessage(textBudget int, includeThinking bool) UIMessag
}
messageID, _ := evt.Get("messageId").(string)
ensureThinkingPart(messageID).content.WriteString(delta)
+ case agui.EventReasoningMsgChunk:
+ delta, _ := evt.Get("delta").(string)
+ if delta == "" {
+ continue
+ }
+ if !includeThinking {
+ continue
+ }
+ messageID, _ := evt.Get("messageId").(string)
+ ensureThinkingPart(messageID).content.WriteString(delta)
case agui.EventReasoningMsgEnd:
messageID, _ := evt.Get("messageId").(string)
closeThinkingPart(messageID)
@@ -583,6 +602,36 @@ func (t Run) FinalBeeperAIMessage(textBudget int, includeThinking bool) UIMessag
stepParts[id] = part
}
part["state"] = state
+ case agui.EventActivitySnapshot:
+ messageID := firstString(evt.Get("messageId"), t.MessageID)
+ activityType := firstString(evt.Get("activityType"), "activity")
+ id := activityPartID(messageID, activityType)
+ content, _ := evt.Get("content").(map[string]any)
+ activityContents[id] = cloneMap(content)
+ part := activityParts[id]
+ if part == nil {
+ part = appendPart(MessagePart{"type": "thinking", "id": id, "messageId": messageID})
+ activityParts[id] = part
+ }
+ applyActivityContent(part, activityType, activityContents[id])
+ case agui.EventActivityDelta:
+ messageID := firstString(evt.Get("messageId"), t.MessageID)
+ activityType := firstString(evt.Get("activityType"), "activity")
+ id := activityPartID(messageID, activityType)
+ content := activityContents[id]
+ if content == nil {
+ content = map[string]any{}
+ activityContents[id] = content
+ }
+ if patch, ok := evt.Get("patch").([]any); ok {
+ applyJSONPatch(content, patch)
+ }
+ part := activityParts[id]
+ if part == nil {
+ part = appendPart(MessagePart{"type": "thinking", "id": id, "messageId": messageID})
+ activityParts[id] = part
+ }
+ applyActivityContent(part, activityType, content)
case agui.EventCustom:
name, _ := evt.Get("name").(string)
value, _ := evt.Get("value").(map[string]any)
@@ -632,17 +681,58 @@ func (t Run) FinalBeeperAIMessage(textBudget int, includeThinking bool) UIMessag
projected.part["content"] = projected.content.String()
compactTextPart(projected.part, textBudget)
}
+ emptyThinkingPartIDs := map[string]struct{}{}
for _, projected := range thinkingParts {
content := projected.content.String()
if content == "" {
- content = "Thinking..."
+ if t.Status.State != "" && t.Status.State != "streaming" {
+ if id := firstString(projected.part["id"]); id != "" {
+ emptyThinkingPartIDs[id] = struct{}{}
+ }
+ continue
+ }
}
projected.part["content"] = content
compactTextPart(projected.part, textBudget)
}
+ message.Parts = filterFinalMessageParts(message.Parts, emptyThinkingPartIDs)
return message
}
+func filterFinalMessageParts(parts []MessagePart, emptyThinkingPartIDs map[string]struct{}) []MessagePart {
+ lastNativeOpenAIWebSearchByQuery := map[string]int{}
+ for index, part := range parts {
+ if query := nativeOpenAIWebSearchQuery(part); query != "" {
+ lastNativeOpenAIWebSearchByQuery[query] = index
+ }
+ }
+ out := parts[:0]
+ for index, part := range parts {
+ if part["type"] == "thinking" {
+ if _, ok := emptyThinkingPartIDs[firstString(part["id"])]; ok {
+ continue
+ }
+ }
+ if query := nativeOpenAIWebSearchQuery(part); query != "" && lastNativeOpenAIWebSearchByQuery[query] != index {
+ continue
+ }
+ out = append(out, part)
+ }
+ return out
+}
+
+func nativeOpenAIWebSearchQuery(part MessagePart) string {
+ if part["type"] != "tool-call" || firstString(part["name"]) != "web_search" {
+ return ""
+ }
+ output, _ := part["output"].(map[string]any)
+ if output == nil || output["native"] != true {
+ return ""
+ }
+ input, _ := part["input"].(map[string]any)
+ return firstString(output["query"], input["query"])
+}
+
func toolResultOutput(content string, state string, err any) any {
output := jsonValue(content)
result, ok := output.(map[string]any)
@@ -760,6 +850,7 @@ func isReasoningEventType(eventType string) bool {
agui.EventReasoningEnd,
agui.EventReasoningMsgStart,
agui.EventReasoningMsgCont,
+ agui.EventReasoningMsgChunk,
agui.EventReasoningMsgEnd:
return true
default:
@@ -787,7 +878,9 @@ func isActivityEventType(eventType string) bool {
case agui.EventToolCallStart,
agui.EventToolCallArgs,
agui.EventToolCallEnd,
- agui.EventToolCallResult:
+ agui.EventToolCallResult,
+ agui.EventActivitySnapshot,
+ agui.EventActivityDelta:
return true
default:
return false
@@ -845,6 +938,129 @@ func asString(value any) string {
}
}
+func activityPartID(messageID string, activityType string) string {
+ return fmt.Sprintf("%s:activity:%s", messageID, activityType)
+}
+
+func applyActivityContent(part MessagePart, activityType string, content map[string]any) {
+ title, text, state := activityDisplay(activityType, content)
+ if text == "" {
+ return
+ }
+ part["content"] = text
+ if title != "" && title != text {
+ part["title"] = title
+ } else {
+ delete(part, "title")
+ }
+ part["stepId"] = firstString(title, text)
+ part["state"] = state
+}
+
+func activityDisplay(activityType string, content map[string]any) (title string, text string, state string) {
+ title = firstString(content["title"], content["label"], content["name"], activityTitle(activityType))
+ text = firstString(content["text"], content["content"], content["message"], content["summary"], content["description"], content["status"])
+ if text == "" {
+ text = title
+ title = ""
+ }
+ status := strings.ToLower(firstString(content["state"], content["status"], content["phase"]))
+ switch status {
+ case "done", "complete", "completed", "finished", "success", "failed", "error", "cancelled", "canceled":
+ state = agui.PartStateDone
+ default:
+ state = agui.PartStateStreaming
+ }
+ return title, text, state
+}
+
+func activityTitle(activityType string) string {
+ text := strings.NewReplacer("_", " ", "-", " ", ".", " ").Replace(strings.TrimSpace(activityType))
+ fields := strings.Fields(text)
+ for i, field := range fields {
+ if field == "" {
+ continue
+ }
+ fields[i] = strings.ToUpper(field[:1]) + field[1:]
+ }
+ return strings.Join(fields, " ")
+}
+
+func cloneMap(value map[string]any) map[string]any {
+ out := map[string]any{}
+ for key, item := range value {
+ out[key] = item
+ }
+ return out
+}
+
+func applyJSONPatch(target map[string]any, patch []any) {
+ for _, rawOperation := range patch {
+ operation, ok := rawOperation.(map[string]any)
+ if !ok {
+ continue
+ }
+ path := firstString(operation["path"])
+ if path == "" {
+ continue
+ }
+ switch firstString(operation["op"]) {
+ case "add", "replace":
+ setJSONPointer(target, path, operation["value"])
+ case "remove":
+ removeJSONPointer(target, path)
+ }
+ }
+}
+
+func setJSONPointer(target map[string]any, path string, value any) {
+ parts := jsonPointerParts(path)
+ if len(parts) == 0 {
+ return
+ }
+ current := target
+ for _, part := range parts[:len(parts)-1] {
+ next, _ := current[part].(map[string]any)
+ if next == nil {
+ next = map[string]any{}
+ current[part] = next
+ }
+ current = next
+ }
+ current[parts[len(parts)-1]] = value
+}
+
+func removeJSONPointer(target map[string]any, path string) {
+ parts := jsonPointerParts(path)
+ if len(parts) == 0 {
+ return
+ }
+ current := target
+ for _, part := range parts[:len(parts)-1] {
+ next, _ := current[part].(map[string]any)
+ if next == nil {
+ return
+ }
+ current = next
+ }
+ delete(current, parts[len(parts)-1])
+}
+
+func jsonPointerParts(path string) []string {
+ if path == "" || path == "/" || !strings.HasPrefix(path, "/") {
+ return nil
+ }
+ raw := strings.Split(strings.TrimPrefix(path, "/"), "/")
+ parts := make([]string, 0, len(raw))
+ for _, part := range raw {
+ part = strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~")
+ if part != "" {
+ parts = append(parts, part)
+ }
+ }
+ return parts
+}
+
func cloneValueMap(value map[string]any) MessagePart {
cp := make(MessagePart, len(value)+1)
for key, item := range value {
diff --git a/pkg/ai-stream/run.go b/pkg/ai-stream/run.go
index f7e09777..991f5032 100644
--- a/pkg/ai-stream/run.go
+++ b/pkg/ai-stream/run.go
@@ -4,6 +4,7 @@ import (
"fmt"
"maps"
"slices"
+ "strconv"
"strings"
"time"
"unicode/utf8"
@@ -183,8 +184,13 @@ type Writer struct {
builder agui.EventBuilder
textMessages map[int]string
textOpen map[int]bool
+ textContentWritten bool
+ textIndexOffset int
+ currentAssistantMessageID string
reasoningMessages map[int]string
reasoningOpen map[int]bool
+ reasoningContent map[int]string
+ reasoningIndexOffset int
reasoningPhaseID string
reasoningPhaseOpen bool
nextSyntheticReasoningIdx int
@@ -226,17 +232,24 @@ func NewRun(runID, threadID, model, agentID, agentName string, now time.Time) *R
}
func NewWriter(run *Run, now func() time.Time) *Writer {
- return &Writer{
- Run: run,
- builder: agui.NewEventBuilder(run.Model, now),
- textMessages: map[int]string{},
- textOpen: map[int]bool{},
- reasoningMessages: map[int]string{},
- reasoningOpen: map[int]bool{},
- reasoningPhaseID: "reasoning-" + run.RunID,
- lastAccountedChars: utf8.RuneCountInString(run.Text()),
- previewText: run.Text(),
- }
+ textIndexOffset := nextMessageOrdinal(run, messageOrdinalText)
+ reasoningIndexOffset := nextMessageOrdinal(run, messageOrdinalReasoning)
+ writer := &Writer{
+ Run: run,
+ builder: agui.NewEventBuilder(run.Model, now),
+ textMessages: map[int]string{},
+ textOpen: map[int]bool{},
+ textIndexOffset: textIndexOffset,
+ reasoningMessages: map[int]string{},
+ reasoningOpen: map[int]bool{},
+ reasoningContent: map[int]string{},
+ reasoningIndexOffset: reasoningIndexOffset,
+ reasoningPhaseID: "reasoning-" + run.RunID,
+ lastAccountedChars: utf8.RuneCountInString(run.Text()),
+ previewText: run.Text(),
+ }
+ writer.currentAssistantMessageID = writer.textMessageID(0)
+ return writer
}
func (w *Writer) Add(evt agui.Event) {
@@ -264,9 +277,14 @@ func (w *Writer) TextDelta(index int, delta string) {
return
}
messageID := w.ensureTextMessage(index)
+ w.textContentWritten = true
w.Add(w.builder.TextMessageContent(messageID, delta))
}
+func (w *Writer) HasTextContent() bool {
+ return w != nil && w.textContentWritten
+}
+
func (w *Writer) TextEnd(index int) {
w.initState()
messageID := w.textMessages[index]
@@ -288,6 +306,7 @@ func (w *Writer) Thinking(delta string) {
}
func (w *Writer) ReasoningMessageStart(index int) string {
+ w.ensureCurrentAssistantMessage()
w.ensureReasoningPhase()
return w.ensureReasoningMessage(index)
}
@@ -297,9 +316,28 @@ func (w *Writer) ReasoningDelta(index int, delta string) {
return
}
messageID := w.ReasoningMessageStart(index)
+ w.reasoningContent[index] += delta
w.Add(w.builder.ReasoningMessageContent(messageID, delta))
}
+func (w *Writer) ReasoningContentSnapshot(index int, content string) {
+ if content == "" {
+ return
+ }
+ w.initState()
+ previous := w.reasoningContent[index]
+ if content == previous {
+ return
+ }
+ if previous == "" {
+ w.ReasoningDelta(index, content)
+ return
+ }
+ if strings.HasPrefix(content, previous) {
+ w.ReasoningDelta(index, content[len(previous):])
+ }
+}
+
func (w *Writer) ReasoningMessageEnd(index int) {
w.initState()
messageID := w.reasoningMessages[index]
@@ -311,11 +349,13 @@ func (w *Writer) ReasoningMessageEnd(index int) {
}
func (w *Writer) StepStart(stepID string) {
- w.Add(w.builder.StepStarted(w.Run.MessageID, stepID))
+ messageID := w.ensureCurrentAssistantMessage()
+ w.Add(w.builder.StepStarted(messageID, stepID))
}
func (w *Writer) StepFinish(stepID string) {
- w.Add(w.builder.StepFinished(w.Run.MessageID, stepID))
+ messageID := w.ensureCurrentAssistantMessage()
+ w.Add(w.builder.StepFinished(messageID, stepID))
}
func (w *Writer) ToolStart(toolCallID, name string, index int, approval *ToolApproval) {
@@ -324,7 +364,7 @@ func (w *Writer) ToolStart(toolCallID, name string, index int, approval *ToolApp
func (w *Writer) ToolStartWithMetadata(toolCallID, name string, index int, approval *ToolApproval, metadata map[string]any) {
idx := index
- w.Add(w.builder.ToolCallStartWithMetadata(w.Run.MessageID, toolCallID, name, &idx, metadata))
+ w.Add(w.builder.ToolCallStartWithMetadata(w.currentAssistantMessage(), toolCallID, name, &idx, metadata))
if approval != nil {
w.recordApprovalRequest(toolCallID, name, approval)
}
@@ -627,6 +667,7 @@ func (w *Writer) finishText() {
func (w *Writer) ensureTextMessage(index int) string {
w.initState()
if messageID := w.textMessages[index]; messageID != "" {
+ w.currentAssistantMessageID = messageID
if !w.textOpen[index] {
w.Add(w.builder.TextMessageStart(messageID, agui.RoleAssistant))
w.textOpen[index] = true
@@ -635,6 +676,34 @@ func (w *Writer) ensureTextMessage(index int) string {
}
messageID := w.textMessageID(index)
w.textMessages[index] = messageID
+ w.currentAssistantMessageID = messageID
+ w.Add(w.builder.TextMessageStart(messageID, agui.RoleAssistant))
+ w.textOpen[index] = true
+ return messageID
+}
+
+func (w *Writer) currentAssistantMessage() string {
+ w.initState()
+ if w.currentAssistantMessageID == "" {
+ w.currentAssistantMessageID = w.textMessageID(0)
+ }
+ return w.currentAssistantMessageID
+}
+
+func (w *Writer) ensureCurrentAssistantMessage() string {
+ messageID := w.currentAssistantMessage()
+ for index, existingID := range w.textMessages {
+ if existingID != messageID {
+ continue
+ }
+ if !w.textOpen[index] {
+ w.Add(w.builder.TextMessageStart(messageID, agui.RoleAssistant))
+ w.textOpen[index] = true
+ }
+ return messageID
+ }
+ index := w.textIndexForMessageID(messageID)
+ w.textMessages[index] = messageID
w.Add(w.builder.TextMessageStart(messageID, agui.RoleAssistant))
w.textOpen[index] = true
return messageID
@@ -668,10 +737,11 @@ func (w *Writer) ensureReasoningMessage(index int) string {
}
func (w *Writer) textMessageID(index int) string {
- if index <= 0 {
+ ordinal := w.textIndexOffset + max(index, 0)
+ if ordinal == 0 {
return w.Run.MessageID
}
- return fmt.Sprintf("%s-text-%d", w.Run.MessageID, index)
+ return fmt.Sprintf("%s-text-%d", w.Run.MessageID, ordinal)
}
func (w *Writer) reasoningMessageID(index int) string {
@@ -679,7 +749,19 @@ func (w *Writer) reasoningMessageID(index int) string {
index = w.nextSyntheticReasoningIdx
w.nextSyntheticReasoningIdx++
}
- return fmt.Sprintf("%s-reasoning-%d", w.Run.MessageID, index)
+ return fmt.Sprintf("%s-reasoning-%d", w.Run.MessageID, w.reasoningIndexOffset+max(index, 0))
+}
+
+func (w *Writer) textIndexForMessageID(messageID string) int {
+ ordinal, ok := messageOrdinal(w.Run.MessageID, messageID, messageOrdinalText)
+ if !ok {
+ return 0
+ }
+ index := ordinal - w.textIndexOffset
+ if index < 0 {
+ return 0
+ }
+ return index
}
func (w *Writer) toolResultMessageID(toolCallID string) string {
@@ -730,6 +812,9 @@ func (w *Writer) initState() {
if w.reasoningOpen == nil {
w.reasoningOpen = map[int]bool{}
}
+ if w.reasoningContent == nil {
+ w.reasoningContent = map[int]string{}
+ }
if w.reasoningPhaseID == "" && w.Run != nil {
w.reasoningPhaseID = "reasoning-" + w.Run.RunID
}
@@ -745,6 +830,82 @@ func sortedOpenIndexes(open map[int]bool) []int {
return slices.Sorted(maps.Keys(indexes))
}
+type messageOrdinalKind int
+
+const (
+ messageOrdinalText messageOrdinalKind = iota
+ messageOrdinalReasoning
+)
+
+func nextMessageOrdinal(run *Run, kind messageOrdinalKind) int {
+ if run == nil {
+ return 0
+ }
+ maxOrdinal := -1
+ for _, evt := range run.Events {
+ if !messageOrdinalEvent(evt.Type(), kind) {
+ continue
+ }
+ messageID, _ := evt.Get("messageId").(string)
+ if messageID == "" {
+ continue
+ }
+ ordinal, ok := messageOrdinal(run.MessageID, messageID, kind)
+ if ok && ordinal > maxOrdinal {
+ maxOrdinal = ordinal
+ }
+ }
+ if maxOrdinal < 0 {
+ return 0
+ }
+ return maxOrdinal + 1
+}
+
+func messageOrdinalEvent(eventType string, kind messageOrdinalKind) bool {
+ switch kind {
+ case messageOrdinalText:
+ switch eventType {
+ case agui.EventTextMessageStart, agui.EventTextMessageContent, agui.EventTextMessageChunk, agui.EventTextMessageEnd:
+ return true
+ default:
+ return false
+ }
+ case messageOrdinalReasoning:
+ switch eventType {
+ case agui.EventReasoningMsgStart, agui.EventReasoningMsgCont, agui.EventReasoningMsgChunk, agui.EventReasoningMsgEnd:
+ return true
+ default:
+ return false
+ }
+ default:
+ return false
+ }
+}
+
+func messageOrdinal(baseID, messageID string, kind messageOrdinalKind) (int, bool) {
+ switch kind {
+ case messageOrdinalText:
+ if messageID == baseID {
+ return 0, true
+ }
+ prefix := baseID + "-text-"
+ if !strings.HasPrefix(messageID, prefix) {
+ return 0, false
+ }
+ ordinal, err := strconv.Atoi(strings.TrimPrefix(messageID, prefix))
+ return ordinal, err == nil && ordinal > 0
+ case messageOrdinalReasoning:
+ prefix := baseID + "-reasoning-"
+ if !strings.HasPrefix(messageID, prefix) {
+ return 0, false
+ }
+ ordinal, err := strconv.Atoi(strings.TrimPrefix(messageID, prefix))
+ return ordinal, err == nil && ordinal >= 0
+ default:
+ return 0, false
+ }
+}
+
func toolResultState(result any) string {
value := result
if raw, ok := result.(string); ok {
diff --git a/pkg/ai-stream/stream_test.go b/pkg/ai-stream/stream_test.go
index 8d19a3c5..2c6f6c1c 100644
--- a/pkg/ai-stream/stream_test.go
+++ b/pkg/ai-stream/stream_test.go
@@ -195,6 +195,78 @@ func TestWriterKeepsReasoningMessagesSeparate(t *testing.T) {
}
}
+func TestWriterAnchorsContinuationReasoningBeforeTextToFreshAssistantMessage(t *testing.T) {
+ run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
+ writer := NewWriter(run, func() time.Time { return time.Unix(10, 0) })
+ writer.Start()
+ writer.Text("before ")
+ writer.ToolStart("tool-1", "ask_approval", 1, nil)
+ writer.AwaitToolUseWithUsage(nil)
+
+ continuation := NewWriter(run, func() time.Time { return time.Unix(11, 0) })
+ continuation.ReasoningMessageStart(0)
+ continuation.ReasoningDelta(0, "checking approval")
+ continuation.Text("after")
+
+ var ordered []string
+ for _, evt := range run.Events {
+ switch evt.Type() {
+ case agui.EventTextMessageStart:
+ ordered = append(ordered, "text-start:"+asString(evt.Get("messageId")))
+ case agui.EventReasoningMsgStart:
+ ordered = append(ordered, "thinking-start:"+asString(evt.Get("messageId")))
+ case agui.EventReasoningMsgCont:
+ ordered = append(ordered, "thinking:"+asString(evt.Get("delta")))
+ case agui.EventTextMessageContent:
+ if delta := asString(evt.Get("delta")); delta != "" {
+ ordered = append(ordered, "text:"+asString(evt.Get("messageId"))+":"+delta)
+ }
+ }
+ }
+ got := strings.Join(ordered, "|")
+ if !strings.Contains(got, "text-start:msg-run-1-text-1|thinking-start:msg-run-1-reasoning-0|thinking:checking approval|text:msg-run-1-text-1:after") {
+ t.Fatalf("continuation reasoning was not anchored to the fresh assistant message:\n%s", got)
+ }
+
+ uiMessage := run.FinalBeeperAIMessage(0, true)
+ gotParts := uiPartSummary(uiMessage.Parts)
+ wantParts := []string{"text:before ", "tool-call:tool-1", "thinking:checking approval", "text:after"}
+ if !reflect.DeepEqual(gotParts, wantParts) {
+ t.Fatalf("continued reasoning UI parts mismatch\ngot: %#v\nwant: %#v\nparts: %#v", gotParts, wantParts, uiMessage.Parts)
+ }
+}
+
+func TestWriterParentsContinuationToolsToCurrentAssistantMessage(t *testing.T) {
+ run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
+ writer := NewWriter(run, func() time.Time { return time.Unix(10, 0) })
+ writer.Start()
+ writer.Text("before ")
+ writer.ToolStart("tool-1", "ask_approval", 1, nil)
+ writer.AwaitToolUseWithUsage(nil)
+
+ continuation := NewWriter(run, func() time.Time { return time.Unix(11, 0) })
+ continuation.Text("after ")
+ continuation.ToolStart("tool-2", "second_tool", 1, nil)
+
+ parentByTool := map[string]string{}
+ for _, evt := range run.Events {
+ if evt.Type() != agui.EventToolCallStart {
+ continue
+ }
+ parentByTool[asString(evt.Get("toolCallId"))] = asString(evt.Get("parentMessageId"))
+ }
+ if parentByTool["tool-2"] != "msg-run-1-text-1" {
+ t.Fatalf("second continuation tool used wrong parent: %#v", parentByTool)
+ }
+
+ uiMessage := run.FinalBeeperAIMessage(0, true)
+ gotParts := uiPartSummary(uiMessage.Parts)
+ wantParts := []string{"text:before ", "tool-call:tool-1", "text:after ", "tool-call:tool-2"}
+ if !reflect.DeepEqual(gotParts, wantParts) {
+ t.Fatalf("continued tool UI parts mismatch\ngot: %#v\nwant: %#v\nparts: %#v", gotParts, wantParts, uiMessage.Parts)
+ }
+}
+
func TestInterleavedReasoningContentStaysSeparateInFinalProjections(t *testing.T) {
run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
builder := agui.NewEventBuilder(DefaultModel, func() time.Time { return time.Unix(10, 0) })
@@ -324,7 +396,7 @@ func TestFinalBeeperAIMessagePreservesTextChunksAfterToolCalls(t *testing.T) {
}
}
-func TestFinalBeeperAIMessageShowsHiddenReasoningInOrder(t *testing.T) {
+func TestFinalBeeperAIMessageSuppressesEmptyHiddenReasoningInOrder(t *testing.T) {
run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
builder := agui.NewEventBuilder(DefaultModel, func() time.Time { return time.Unix(10, 0) })
run.Status = Status{State: "complete", FinishReason: agui.FinishReasonStop}
@@ -354,7 +426,6 @@ func TestFinalBeeperAIMessageShowsHiddenReasoningInOrder(t *testing.T) {
want := []string{
"text:first text",
"tool-call:tool-1",
- "thinking:Thinking...",
"text:second text",
}
if !reflect.DeepEqual(got, want) {
@@ -362,6 +433,73 @@ func TestFinalBeeperAIMessageShowsHiddenReasoningInOrder(t *testing.T) {
}
}
+func TestFinalBeeperAIMessageKeepsStreamingThinkingPartEmptyUntilTokensArrive(t *testing.T) {
+ run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
+ writer := NewWriter(run, func() time.Time { return time.Unix(10, 0) })
+ writer.Start()
+ writer.ReasoningMessageStart(0)
+
+ streamingEmpty := run.FinalBeeperAIMessage(0, true)
+ if len(streamingEmpty.Parts) != 1 || streamingEmpty.Parts[0]["type"] != "thinking" || streamingEmpty.Parts[0]["content"] != "" || streamingEmpty.Parts[0]["state"] != agui.PartStateStreaming {
+ t.Fatalf("streaming empty reasoning should render as an empty in-progress part, got %#v", streamingEmpty.Parts)
+ }
+
+ writer.ReasoningDelta(0, "reading sources")
+ streamingContent := run.FinalBeeperAIMessage(0, true)
+ if len(streamingContent.Parts) != 1 || streamingContent.Parts[0]["type"] != "thinking" || streamingContent.Parts[0]["content"] != "reading sources" || streamingContent.Parts[0]["state"] != agui.PartStateStreaming {
+ t.Fatalf("streaming reasoning tokens should update the same thinking part, got %#v", streamingContent.Parts)
+ }
+
+ writer.ReasoningMessageEnd(0)
+ writer.Finish(agui.FinishReasonStop)
+ finalContent := run.FinalBeeperAIMessage(0, true)
+ if len(finalContent.Parts) != 1 || finalContent.Parts[0]["type"] != "thinking" || finalContent.Parts[0]["content"] != "reading sources" || finalContent.Parts[0]["state"] != agui.PartStateDone {
+ t.Fatalf("final reasoning tokens should remain in order as a done thinking part, got %#v", finalContent.Parts)
+ }
+}
+
+func TestFinalBeeperAIMessageDedupesNativeWebSearchRows(t *testing.T) {
+ for _, provider := range []string{"openai", "openrouter"} {
+ t.Run(provider, func(t *testing.T) {
+ run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
+ writer := NewWriter(run, func() time.Time { return time.Unix(10, 0) })
+ query := "pop culture latest Oscars Grammys Wikipedia"
+ writer.ToolStart("ws_aggregate", "web_search", 0, nil)
+ writer.ToolEnd("ws_aggregate", "web_search", map[string]any{
+ "query": query,
+ "queries": []any{query, "latest entertainment news film music television"},
+ }, map[string]any{
+ "state": "complete",
+ "status": "success",
+ "provider": provider,
+ "native": true,
+ "query": query,
+ "queries": []any{query, "latest entertainment news film music television"},
+ })
+ writer.ToolStart("ws_single", "web_search", 0, nil)
+ writer.ToolEnd("ws_single", "web_search", map[string]any{"query": query}, map[string]any{
+ "state": "complete",
+ "status": "success",
+ "provider": provider,
+ "native": true,
+ "query": query,
+ })
+ writer.Finish(agui.FinishReasonStop)
+
+ uiMessage := run.FinalBeeperAIMessage(0, true)
+ var webSearchIDs []string
+ for _, part := range uiMessage.Parts {
+ if part["type"] == "tool-call" && part["name"] == "web_search" {
+ webSearchIDs = append(webSearchIDs, firstString(part["toolCallId"]))
+ }
+ }
+ if !reflect.DeepEqual(webSearchIDs, []string{"ws_single"}) {
+ t.Fatalf("expected only the final native web_search row, got IDs=%#v parts=%#v", webSearchIDs, uiMessage.Parts)
+ }
+ })
+ }
+}
+
func TestFinalBeeperAIMessagePreservesStepsAsThinking(t *testing.T) {
run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
builder := agui.NewEventBuilder(DefaultModel, func() time.Time { return time.Unix(10, 0) })
@@ -386,6 +524,28 @@ func TestFinalBeeperAIMessagePreservesStepsAsThinking(t *testing.T) {
}
}
+func TestFinalBeeperAIMessagePreservesActivitySnapshots(t *testing.T) {
+ run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
+ builder := agui.NewEventBuilder(DefaultModel, func() time.Time { return time.Unix(10, 0) })
+ run.Events = append(run.Events,
+ builder.ActivitySnapshot(run.MessageID, "web_search", map[string]any{"title": "Searching web", "text": "Looking up docs"}, nil),
+ builder.ActivityDelta(run.MessageID, "web_search", []any{map[string]any{"op": "replace", "path": "/text", "value": "Found docs"}}),
+ builder.ActivityDelta(run.MessageID, "web_search", []any{map[string]any{"op": "replace", "path": "/status", "value": "completed"}}),
+ )
+
+ uiMessage := run.FinalBeeperAIMessage(0, true)
+ got := make([]string, 0, len(uiMessage.Parts))
+ for _, part := range uiMessage.Parts {
+ if part["type"] == "thinking" {
+ got = append(got, fmt.Sprintf("thinking:%s:%s:%s", part["title"], part["content"], part["state"]))
+ }
+ }
+ want := []string{"thinking:Searching web:Found docs:done"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("final UIMessage activity mismatch\ngot: %#v\nwant: %#v\nparts: %#v", got, want, uiMessage.Parts)
+ }
+}
+
func TestPackRunPreservesLargeCustomEvent(t *testing.T) {
run := NewRun("run-1", "thread-1", DefaultModel, "ai", "AI", time.Unix(10, 0))
builder := agui.NewEventBuilder(DefaultModel, func() time.Time { return time.Unix(10, 0) })
@@ -945,3 +1105,16 @@ func TestApprovalQueueKeepsOneActiveInterruptAndTimeouts(t *testing.T) {
t.Fatalf("bad timed-out tool result: %#v", result)
}
}
+
+func uiPartSummary(parts []MessagePart) []string {
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ switch part["type"] {
+ case "text", "thinking":
+ out = append(out, fmt.Sprintf("%s:%s", part["type"], part["content"]))
+ case "tool-call":
+ out = append(out, fmt.Sprintf("tool-call:%s", firstString(part["toolCallId"], part["id"])))
+ }
+ }
+ return out
+}
diff --git a/pkg/ai/image_models.go b/pkg/ai/image_models.go
deleted file mode 100644
index d353630b..00000000
--- a/pkg/ai/image_models.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package ai
-
-import "slices"
-
-func GetImageModel(provider ImagesProvider, modelID string) (ImagesModel, bool) {
- providerModels := ImageModels[provider]
- if providerModels == nil {
- return ImagesModel{}, false
- }
- model, ok := providerModels[modelID]
- return model, ok
-}
-
-func GetImageProviders() []ImagesProvider {
- return slices.Clone(imageModelProviderOrder)
-}
-
-func GetImageModels(provider ImagesProvider) []ImagesModel {
- providerModels := ImageModels[provider]
- if providerModels == nil {
- return nil
- }
- models := make([]ImagesModel, 0, len(providerModels))
- for _, id := range imageModelIDOrder[provider] {
- models = append(models, providerModels[id])
- }
- return models
-}
diff --git a/pkg/ai/image_models_generated.go b/pkg/ai/image_models_generated.go
deleted file mode 100644
index f94e882c..00000000
--- a/pkg/ai/image_models_generated.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package ai
-
-import "encoding/json"
-
-var imageModelsJSON = `{"openrouter":{"black-forest-labs/flux.2-flex":{"id":"black-forest-labs/flux.2-flex","name":"Black Forest Labs: FLUX.2 Flex","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"black-forest-labs/flux.2-klein-4b":{"id":"black-forest-labs/flux.2-klein-4b","name":"Black Forest Labs: FLUX.2 Klein 4B","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"black-forest-labs/flux.2-max":{"id":"black-forest-labs/flux.2-max","name":"Black Forest Labs: FLUX.2 Max","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"black-forest-labs/flux.2-pro":{"id":"black-forest-labs/flux.2-pro","name":"Black Forest Labs: FLUX.2 Pro","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"bytedance-seed/seedream-4.5":{"id":"bytedance-seed/seedream-4.5","name":"ByteDance Seed: Seedream 4.5","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"google/gemini-2.5-flash-image":{"id":"google/gemini-2.5-flash-image","name":"Google: Nano Banana (Gemini 2.5 Flash Image)","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image","text"],"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.08333333333333334}},"google/gemini-3-pro-image-preview":{"id":"google/gemini-3-pro-image-preview","name":"Google: Nano Banana Pro (Gemini 3 Pro Image Preview)","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image","text"],"cost":{"input":2,"output":12,"cacheRead":0.19999999999999998,"cacheWrite":0.375}},"google/gemini-3.1-flash-image-preview":{"id":"google/gemini-3.1-flash-image-preview","name":"Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image","text"],"cost":{"input":0.5,"output":3,"cacheRead":0,"cacheWrite":0}},"openai/gpt-5-image":{"id":"openai/gpt-5-image","name":"OpenAI: GPT-5 Image","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image","text"],"cost":{"input":10,"output":10,"cacheRead":1.25,"cacheWrite":0}},"openai/gpt-5-image-mini":{"id":"openai/gpt-5-image-mini","name":"OpenAI: GPT-5 Image Mini","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image","text"],"cost":{"input":2.5,"output":2,"cacheRead":0.25,"cacheWrite":0}},"openai/gpt-5.4-image-2":{"id":"openai/gpt-5.4-image-2","name":"OpenAI: GPT-5.4 Image 2","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["image","text"],"output":["image","text"],"cost":{"input":8,"output":15,"cacheRead":2,"cacheWrite":0}},"openrouter/auto":{"id":"openrouter/auto","name":"Auto Router","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["text","image"],"cost":{"input":-1000000,"output":-1000000,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v3":{"id":"recraft/recraft-v3","name":"Recraft: Recraft V3","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4":{"id":"recraft/recraft-v4","name":"Recraft: Recraft V4","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4-pro":{"id":"recraft/recraft-v4-pro","name":"Recraft: Recraft V4 Pro","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4-pro-vector":{"id":"recraft/recraft-v4-pro-vector","name":"Recraft: Recraft V4 Pro Vector","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4-vector":{"id":"recraft/recraft-v4-vector","name":"Recraft: Recraft V4 Vector","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4.1":{"id":"recraft/recraft-v4.1","name":"Recraft: Recraft V4.1","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4.1-pro":{"id":"recraft/recraft-v4.1-pro","name":"Recraft: Recraft V4.1 Pro","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4.1-pro-vector":{"id":"recraft/recraft-v4.1-pro-vector","name":"Recraft: Recraft V4.1 Pro Vector","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4.1-utility":{"id":"recraft/recraft-v4.1-utility","name":"Recraft: Recraft V4.1 Utility","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4.1-utility-pro":{"id":"recraft/recraft-v4.1-utility-pro","name":"Recraft: Recraft V4.1 Utility Pro","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"recraft/recraft-v4.1-vector":{"id":"recraft/recraft-v4.1-vector","name":"Recraft: Recraft V4.1 Vector","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"sourceful/riverflow-v2-fast":{"id":"sourceful/riverflow-v2-fast","name":"Sourceful: Riverflow V2 Fast","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"sourceful/riverflow-v2-fast-preview":{"id":"sourceful/riverflow-v2-fast-preview","name":"Sourceful: Riverflow V2 Fast Preview","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"sourceful/riverflow-v2-max-preview":{"id":"sourceful/riverflow-v2-max-preview","name":"Sourceful: Riverflow V2 Max Preview","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"sourceful/riverflow-v2-pro":{"id":"sourceful/riverflow-v2-pro","name":"Sourceful: Riverflow V2 Pro","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"sourceful/riverflow-v2-standard-preview":{"id":"sourceful/riverflow-v2-standard-preview","name":"Sourceful: Riverflow V2 Standard Preview","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}},"x-ai/grok-imagine-image-quality":{"id":"x-ai/grok-imagine-image-quality","name":"xAI: Grok Imagine Image Quality","api":"openrouter-images","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","input":["text","image"],"output":["image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}}}`
-
-var imageModelProviderOrder = []ImagesProvider{
- "openrouter",
-}
-
-var imageModelIDOrderJSON = `{"openrouter":["black-forest-labs/flux.2-flex","black-forest-labs/flux.2-klein-4b","black-forest-labs/flux.2-max","black-forest-labs/flux.2-pro","bytedance-seed/seedream-4.5","google/gemini-2.5-flash-image","google/gemini-3-pro-image-preview","google/gemini-3.1-flash-image-preview","openai/gpt-5-image","openai/gpt-5-image-mini","openai/gpt-5.4-image-2","openrouter/auto","recraft/recraft-v3","recraft/recraft-v4","recraft/recraft-v4-pro","recraft/recraft-v4-pro-vector","recraft/recraft-v4-vector","recraft/recraft-v4.1","recraft/recraft-v4.1-pro","recraft/recraft-v4.1-pro-vector","recraft/recraft-v4.1-utility","recraft/recraft-v4.1-utility-pro","recraft/recraft-v4.1-vector","sourceful/riverflow-v2-fast","sourceful/riverflow-v2-fast-preview","sourceful/riverflow-v2-max-preview","sourceful/riverflow-v2-pro","sourceful/riverflow-v2-standard-preview","x-ai/grok-imagine-image-quality"]}`
-
-var ImageModels = mustLoadImageModels()
-var imageModelIDOrder = mustLoadImageModelIDOrder()
-
-func mustLoadImageModels() map[ImagesProvider]map[string]ImagesModel {
- var raw map[ImagesProvider]map[string]ImagesModel
- if err := json.Unmarshal([]byte(imageModelsJSON), &raw); err != nil {
- panic(err)
- }
- return raw
-}
-
-func mustLoadImageModelIDOrder() map[ImagesProvider][]string {
- var raw map[ImagesProvider][]string
- if err := json.Unmarshal([]byte(imageModelIDOrderJSON), &raw); err != nil {
- panic(err)
- }
- return raw
-}
diff --git a/pkg/ai/images_test.go b/pkg/ai/images_test.go
index 890e5906..37f50c13 100644
--- a/pkg/ai/images_test.go
+++ b/pkg/ai/images_test.go
@@ -5,24 +5,6 @@ import (
"testing"
)
-func TestImageModelRegistryGeneratedSurface(t *testing.T) {
- providers := GetImageProviders()
- if len(providers) != 1 || providers[0] != ImagesProviderOpenRouter {
- t.Fatalf("unexpected image providers: %#v", providers)
- }
- model, ok := GetImageModel(ImagesProviderOpenRouter, "openai/gpt-5-image")
- if !ok {
- t.Fatal("expected generated image model")
- }
- if model.API != ImagesApiOpenRouter || model.Provider != ImagesProviderOpenRouter {
- t.Fatalf("unexpected image model metadata: %#v", model)
- }
- models := GetImageModels(ImagesProviderOpenRouter)
- if len(models) == 0 || models[0].ID != "black-forest-labs/flux.2-flex" {
- t.Fatalf("unexpected image model order: %#v", models)
- }
-}
-
func TestGenerateImagesMissingProviderPanics(t *testing.T) {
defer func() {
if recovered := recover(); recovered == nil {
diff --git a/pkg/ai/modelcatalog/catalog.go b/pkg/ai/modelcatalog/catalog.go
deleted file mode 100644
index 9f4c37b5..00000000
--- a/pkg/ai/modelcatalog/catalog.go
+++ /dev/null
@@ -1,420 +0,0 @@
-package modelcatalog
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "maps"
- "net/http"
- "regexp"
- "slices"
- "strings"
-
- "github.com/beeper/ai-bridge/pkg/ai"
-)
-
-const (
- ModelsDevURL = "https://models.dev/api.json"
- OpenRouterModelsURL = "https://openrouter.ai/api/v1/models"
- VertexBaseURL = "https://{location}-aiplatform.googleapis.com"
-)
-
-var (
- DefaultProviders = []ai.Provider{
- ai.ProviderOpenAI,
- ai.ProviderOpenRouter,
- ai.ProviderAnthropic,
- ai.ProviderGoogleVertex,
- }
-
- DefaultRegisteredAPIs = []ai.Api{
- ai.ApiAnthropicMessages,
- ai.ApiOpenAICompletions,
- ai.ApiOpenAIResponses,
- ai.ApiOpenAICodexResponses,
- ai.ApiGoogleVertex,
- }
-
- gemini3ProPattern = regexp.MustCompile(`(?i)gemini-3(?:\.\d+)?-pro`)
- gemini3FlashPattern = regexp.MustCompile(`(?i)gemini-3(?:\.\d+)?-flash`)
-)
-
-type Options struct {
- HTTPClient *http.Client
- ModelsDevURL string
- OpenRouterModelsURL string
- Providers []ai.Provider
- RegisteredAPIs []ai.Api
- IncludeUnregistered bool
-}
-
-type Catalog struct {
- Models map[ai.Provider]map[string]ai.Model
- ProviderOrder []ai.Provider
- ModelIDOrder map[ai.Provider][]string
- Skipped []ai.Provider
-}
-
-func Build(ctx context.Context, opts Options) (Catalog, error) {
- opts = opts.withDefaults()
- modelsDev, openRouter, err := fetchSources(ctx, opts)
- if err != nil {
- return Catalog{}, err
- }
-
- models := make([]ai.Model, 0)
- for _, provider := range []ai.Provider{ai.ProviderOpenAI, ai.ProviderAnthropic, ai.ProviderGoogleVertex} {
- sourceModels := modelsDev[string(provider)].Models
- for _, sourceModel := range sourceModels {
- if !isToolCapableModelsDev(sourceModel) {
- continue
- }
- models = append(models, applyModelMetadata(normalizeModelsDevModel(provider, sourceModel)))
- }
- }
- for _, sourceModel := range openRouter.Data {
- if !isToolCapableOpenRouter(sourceModel) {
- continue
- }
- models = append(models, applyModelMetadata(normalizeOpenRouterModel(sourceModel)))
- }
- return BuildFromModels(models, opts), nil
-}
-
-func BuildFromModels(models []ai.Model, opts Options) Catalog {
- opts = opts.withDefaults()
- targetProviders := providerSet(opts.Providers)
- registeredAPIs := apiSet(opts.RegisteredAPIs)
-
- allProviders := make(map[ai.Provider]bool)
- out := make(map[ai.Provider]map[string]ai.Model)
- for _, model := range models {
- if !targetProviders[model.Provider] {
- continue
- }
- allProviders[model.Provider] = true
- if !opts.IncludeUnregistered && !registeredAPIs[model.API] {
- continue
- }
- if out[model.Provider] == nil {
- out[model.Provider] = make(map[string]ai.Model)
- }
- out[model.Provider][model.ID] = model
- }
-
- providerOrder := slices.Sorted(maps.Keys(out))
- modelIDOrder := make(map[ai.Provider][]string, len(out))
- for provider, providerModels := range out {
- modelIDOrder[provider] = slices.Sorted(maps.Keys(providerModels))
- }
-
- skipped := make([]ai.Provider, 0)
- emittedProviders := providerSet(providerOrder)
- for provider := range allProviders {
- if !emittedProviders[provider] {
- skipped = append(skipped, provider)
- }
- }
- slices.Sort(skipped)
-
- return Catalog{
- Models: out,
- ProviderOrder: providerOrder,
- ModelIDOrder: modelIDOrder,
- Skipped: skipped,
- }
-}
-
-func (catalog Catalog) Count() int {
- count := 0
- for _, providerModels := range catalog.Models {
- count += len(providerModels)
- }
- return count
-}
-
-func GenerateGoSource(catalog Catalog, packageName string) ([]byte, error) {
- if packageName == "" {
- packageName = "ai"
- }
- modelsJSON, err := json.Marshal(catalog.Models)
- if err != nil {
- return nil, fmt.Errorf("marshal models: %w", err)
- }
- modelIDOrderJSON, err := json.Marshal(catalog.ModelIDOrder)
- if err != nil {
- return nil, fmt.Errorf("marshal model id order: %w", err)
- }
-
- var buf bytes.Buffer
- fmt.Fprintf(&buf, "package %s\n\n", packageName)
- buf.WriteString("import \"encoding/json\"\n\n")
- fmt.Fprintf(&buf, "var modelsJSON = `%s`\n\n", escapeRawString(modelsJSON))
- buf.WriteString("var modelProviderOrder = []Provider{\n")
- for _, provider := range catalog.ProviderOrder {
- encoded, _ := json.Marshal(provider)
- fmt.Fprintf(&buf, "\t%s,\n", encoded)
- }
- buf.WriteString("}\n\n")
- fmt.Fprintf(&buf, "var modelIDOrderJSON = `%s`\n\n", escapeRawString(modelIDOrderJSON))
- buf.WriteString(`var Models = mustLoadModels()
-var modelIDOrder = mustLoadModelIDOrder()
-
-func mustLoadModels() map[Provider]map[string]Model {
- var raw map[Provider]map[string]Model
- if err := json.Unmarshal([]byte(modelsJSON), &raw); err != nil {
- panic(err)
- }
- return raw
-}
-
-func mustLoadModelIDOrder() map[Provider][]string {
- var raw map[Provider][]string
- if err := json.Unmarshal([]byte(modelIDOrderJSON), &raw); err != nil {
- panic(err)
- }
- return raw
-}
-`)
- return buf.Bytes(), nil
-}
-
-func fetchSources(ctx context.Context, opts Options) (modelsDevResponse, openRouterResponse, error) {
- var modelsDev modelsDevResponse
- var openRouter openRouterResponse
- if err := fetchJSON(ctx, opts.HTTPClient, opts.ModelsDevURL, &modelsDev); err != nil {
- return nil, openRouterResponse{}, fmt.Errorf("fetch models.dev catalog: %w", err)
- }
- if err := fetchJSON(ctx, opts.HTTPClient, opts.OpenRouterModelsURL, &openRouter); err != nil {
- return nil, openRouterResponse{}, fmt.Errorf("fetch OpenRouter catalog: %w", err)
- }
- return modelsDev, openRouter, nil
-}
-
-func fetchJSON(ctx context.Context, client *http.Client, url string, target any) error {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
- if err != nil {
- return err
- }
- resp, err := client.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
- return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(body)))
- }
- return json.NewDecoder(resp.Body).Decode(target)
-}
-
-func (opts Options) withDefaults() Options {
- if opts.HTTPClient == nil {
- opts.HTTPClient = http.DefaultClient
- }
- if opts.ModelsDevURL == "" {
- opts.ModelsDevURL = ModelsDevURL
- }
- if opts.OpenRouterModelsURL == "" {
- opts.OpenRouterModelsURL = OpenRouterModelsURL
- }
- if len(opts.Providers) == 0 {
- opts.Providers = slices.Clone(DefaultProviders)
- }
- if len(opts.RegisteredAPIs) == 0 {
- opts.RegisteredAPIs = slices.Clone(DefaultRegisteredAPIs)
- }
- return opts
-}
-
-func normalizeModelsDevModel(provider ai.Provider, model modelsDevModel) ai.Model {
- api := ai.ApiGoogleVertex
- baseURL := VertexBaseURL
- switch provider {
- case ai.ProviderOpenAI:
- api = openAIAPI(model.ID)
- baseURL = "https://api.openai.com/v1"
- case ai.ProviderAnthropic:
- api = ai.ApiAnthropicMessages
- baseURL = "https://api.anthropic.com"
- }
- name := model.Name
- if name == "" {
- name = model.ID
- }
- if provider == ai.ProviderGoogleVertex {
- name += " (Vertex)"
- }
- return ai.Model{
- ID: model.ID,
- Name: name,
- API: api,
- Provider: provider,
- BaseURL: baseURL,
- Reasoning: model.Reasoning,
- Input: textImageInput(model.Modalities.Input),
- Cost: costFromModelsDev(model.Cost),
- ContextWindow: intOrDefault(model.Limit.Context, 128000),
- MaxTokens: intOrDefault(model.Limit.Output, 16384),
- }
-}
-
-func normalizeOpenRouterModel(model openRouterModel) ai.Model {
- return ai.Model{
- ID: model.ID,
- Name: stringOrDefault(model.Name, model.ID),
- API: ai.ApiOpenAICompletions,
- Provider: ai.ProviderOpenRouter,
- BaseURL: "https://openrouter.ai/api/v1",
- Reasoning: slices.Contains(model.SupportedParameters, "reasoning") || slices.Contains(model.SupportedParameters, "include_reasoning"),
- Input: textImageInput(model.Architecture.InputModalities),
- Cost: costFromOpenRouter(model.Pricing),
- ContextWindow: intOrDefault(firstNonZero(model.ContextLength, model.TopProvider.ContextLength), 128000),
- MaxTokens: intOrDefault(model.TopProvider.MaxCompletionTokens, 16384),
- }
-}
-
-func applyModelMetadata(model ai.Model) ai.Model {
- if (model.API == ai.ApiOpenAIResponses || model.API == ai.ApiOpenAICodexResponses) && strings.HasPrefix(model.ID, "gpt-5") {
- model.ThinkingLevelMap = mergeThinkingLevelMap(model.ThinkingLevelMap, map[ai.ModelThinkingLevel]*string{
- ai.ModelThinkingLevelOff: nil,
- })
- }
- if strings.Contains(model.ID, "gpt-5.2") || strings.Contains(model.ID, "gpt-5.3") || strings.Contains(model.ID, "gpt-5.4") || strings.Contains(model.ID, "gpt-5.5") {
- xhigh := string(ai.ModelThinkingLevelXHigh)
- model.ThinkingLevelMap = mergeThinkingLevelMap(model.ThinkingLevelMap, map[ai.ModelThinkingLevel]*string{
- ai.ModelThinkingLevelXHigh: &xhigh,
- })
- }
- if model.Provider == ai.ProviderOpenRouter && strings.HasPrefix(model.ID, "anthropic/") {
- model.Compat = mergeCompat(model.Compat, map[string]any{"cacheControlFormat": "anthropic"})
- }
- if model.Provider == ai.ProviderGoogleVertex && gemini3ProPattern.MatchString(model.ID) {
- low := "LOW"
- high := "HIGH"
- model.ThinkingLevelMap = mergeThinkingLevelMap(model.ThinkingLevelMap, map[ai.ModelThinkingLevel]*string{
- ai.ModelThinkingLevelOff: nil,
- ai.ModelThinkingLevelMinimal: nil,
- ai.ModelThinkingLevelLow: &low,
- ai.ModelThinkingLevelMedium: nil,
- ai.ModelThinkingLevelHigh: &high,
- })
- }
- if model.Provider == ai.ProviderGoogleVertex && gemini3FlashPattern.MatchString(model.ID) {
- model.ThinkingLevelMap = mergeThinkingLevelMap(model.ThinkingLevelMap, map[ai.ModelThinkingLevel]*string{
- ai.ModelThinkingLevelOff: nil,
- })
- }
- return model
-}
-
-func openAIAPI(modelID string) ai.Api {
- if strings.HasPrefix(modelID, "gpt-5") || strings.HasPrefix(modelID, "o") {
- return ai.ApiOpenAIResponses
- }
- return ai.ApiOpenAICompletions
-}
-
-func costFromModelsDev(cost modelsDevCost) ai.ModelCost {
- return ai.ModelCost{
- Input: cost.Input,
- Output: cost.Output,
- CacheRead: cost.CacheRead,
- CacheWrite: cost.CacheWrite,
- }
-}
-
-func costFromOpenRouter(pricing openRouterPricing) ai.ModelCost {
- return ai.ModelCost{
- Input: pricing.Prompt * 1_000_000,
- Output: pricing.Completion * 1_000_000,
- CacheRead: pricing.InputCacheRead * 1_000_000,
- CacheWrite: pricing.InputCacheWrite * 1_000_000,
- }
-}
-
-func textImageInput(modalities []string) []string {
- input := make([]string, 0, 2)
- if len(modalities) == 0 || slices.Contains(modalities, "text") {
- input = append(input, "text")
- }
- if slices.Contains(modalities, "image") {
- input = append(input, "image")
- }
- return input
-}
-
-func isToolCapableModelsDev(model modelsDevModel) bool {
- return model.ToolCall && slices.Contains(model.Modalities.Output, "text")
-}
-
-func isToolCapableOpenRouter(model openRouterModel) bool {
- return slices.Contains(model.SupportedParameters, "tools")
-}
-
-func providerSet(providers []ai.Provider) map[ai.Provider]bool {
- out := make(map[ai.Provider]bool, len(providers))
- for _, provider := range providers {
- out[provider] = true
- }
- return out
-}
-
-func apiSet(apis []ai.Api) map[ai.Api]bool {
- out := make(map[ai.Api]bool, len(apis))
- for _, api := range apis {
- out[api] = true
- }
- return out
-}
-
-func mergeThinkingLevelMap(base, overlay map[ai.ModelThinkingLevel]*string) map[ai.ModelThinkingLevel]*string {
- out := make(map[ai.ModelThinkingLevel]*string, len(base)+len(overlay))
- for key, value := range base {
- out[key] = value
- }
- for key, value := range overlay {
- out[key] = value
- }
- return out
-}
-
-func mergeCompat(base, overlay map[string]any) map[string]any {
- out := make(map[string]any, len(base)+len(overlay))
- for key, value := range base {
- out[key] = value
- }
- for key, value := range overlay {
- out[key] = value
- }
- return out
-}
-
-func escapeRawString(data []byte) []byte {
- return bytes.ReplaceAll(data, []byte("`"), []byte("`+\"`\"+`"))
-}
-
-func firstNonZero(values ...int) int {
- for _, value := range values {
- if value != 0 {
- return value
- }
- }
- return 0
-}
-
-func intOrDefault(value, fallback int) int {
- if value != 0 {
- return value
- }
- return fallback
-}
-
-func stringOrDefault(value, fallback string) string {
- if value != "" {
- return value
- }
- return fallback
-}
diff --git a/pkg/ai/modelcatalog/catalog_test.go b/pkg/ai/modelcatalog/catalog_test.go
deleted file mode 100644
index 24dd27d8..00000000
--- a/pkg/ai/modelcatalog/catalog_test.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package modelcatalog
-
-import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/beeper/ai-bridge/pkg/ai"
-)
-
-func TestBuildFetchesAndFiltersRuntimeSafeCatalog(t *testing.T) {
- mux := http.NewServeMux()
- mux.HandleFunc("/models.dev", func(w http.ResponseWriter, _ *http.Request) {
- w.Write([]byte(`{
- "openai": {"models": {
- "gpt-5.5": {
- "id": "gpt-5.5",
- "name": "GPT-5.5",
- "reasoning": true,
- "tool_call": true,
- "modalities": {"input": ["text", "image"], "output": ["text"]},
- "cost": {"input": 5, "output": 30, "cache_read": 0.5},
- "limit": {"context": 1050000, "output": 128000}
- }
- }},
- "anthropic": {"models": {
- "claude-sonnet-4-5": {
- "id": "claude-sonnet-4-5",
- "name": "Claude Sonnet 4.5",
- "reasoning": true,
- "tool_call": true,
- "modalities": {"input": ["text", "image"], "output": ["text"]},
- "cost": {"input": 3, "output": 15},
- "limit": {"context": 200000, "output": 64000}
- }
- }},
- "google-vertex": {"models": {
- "gemini-3-pro-preview": {
- "id": "gemini-3-pro-preview",
- "name": "Gemini 3 Pro Preview",
- "reasoning": true,
- "tool_call": true,
- "modalities": {"input": ["text"], "output": ["text"]},
- "cost": {"input": 2, "output": 12},
- "limit": {"context": 1000000, "output": 64000}
- }
- }}
- }`))
- })
- mux.HandleFunc("/openrouter", func(w http.ResponseWriter, _ *http.Request) {
- w.Write([]byte(`{"data": [{
- "id": "anthropic/claude-sonnet-4.5",
- "name": "Anthropic: Claude Sonnet 4.5",
- "supported_parameters": ["tools", "reasoning"],
- "architecture": {"input_modalities": ["text", "image"]},
- "pricing": {"prompt": "0.000003", "completion": "0.000015", "input_cache_read": "0.0000003"},
- "context_length": 200000,
- "top_provider": {"max_completion_tokens": 64000}
- }]}`))
- })
- server := httptest.NewServer(mux)
- defer server.Close()
-
- catalog, err := Build(context.Background(), Options{
- HTTPClient: server.Client(),
- ModelsDevURL: server.URL + "/models.dev",
- OpenRouterModelsURL: server.URL + "/openrouter",
- })
- if err != nil {
- t.Fatal(err)
- }
-
- if catalog.Count() != 4 {
- t.Fatalf("expected runtime-safe catalog to emit four models, got %d", catalog.Count())
- }
- if _, ok := catalog.Models[ai.ProviderAnthropic]["claude-sonnet-4-5"]; !ok {
- t.Fatal("expected direct Anthropic model after provider registration")
- }
- if _, ok := catalog.Models[ai.ProviderGoogleVertex]["gemini-3-pro-preview"]; !ok {
- t.Fatal("expected Vertex model after provider registration")
- }
- if got := catalog.Models[ai.ProviderOpenAI]["gpt-5.5"].ThinkingLevelMap[ai.ModelThinkingLevelXHigh]; got == nil || *got != "xhigh" {
- t.Fatalf("expected gpt-5.5 xhigh metadata, got %#v", got)
- }
- if got := catalog.Models[ai.ProviderOpenRouter]["anthropic/claude-sonnet-4.5"].Compat["cacheControlFormat"]; got != "anthropic" {
- t.Fatalf("expected OpenRouter Anthropic cache control compat, got %#v", got)
- }
- if len(catalog.Skipped) != 0 {
- t.Fatalf("expected no skipped registered providers, got %#v", catalog.Skipped)
- }
-}
-
-func TestBuildCanIncludeUnregisteredProviders(t *testing.T) {
- catalog := BuildFromModels([]ai.Model{
- {ID: "claude-sonnet-4-5", Provider: ai.ProviderAnthropic, API: ai.ApiAnthropicMessages},
- {ID: "gemini-3-pro-preview", Provider: ai.ProviderGoogleVertex, API: ai.ApiGoogleVertex},
- }, Options{IncludeUnregistered: true})
-
- if catalog.Count() != 2 {
- t.Fatalf("expected two models, got %d", catalog.Count())
- }
- if _, ok := catalog.Models[ai.ProviderAnthropic]["claude-sonnet-4-5"]; !ok {
- t.Fatal("expected Anthropic model")
- }
- if _, ok := catalog.Models[ai.ProviderGoogleVertex]["gemini-3-pro-preview"]; !ok {
- t.Fatal("expected Vertex model")
- }
-}
-
-func TestGenerateGoSource(t *testing.T) {
- catalog := BuildFromModels([]ai.Model{{
- ID: "gpt-5",
- Name: "GPT-5",
- API: ai.ApiOpenAIResponses,
- Provider: ai.ProviderOpenAI,
- BaseURL: "https://api.openai.com/v1",
- Input: []string{"text"},
- }}, Options{})
-
- source, err := GenerateGoSource(catalog, "ai")
- if err != nil {
- t.Fatal(err)
- }
- text := string(source)
- for _, want := range []string{"package ai", "var modelsJSON = `", `"gpt-5"`, "func mustLoadModels()"} {
- if !strings.Contains(text, want) {
- t.Fatalf("generated source missing %q:\n%s", want, text)
- }
- }
-}
diff --git a/pkg/ai/modelcatalog/sources.go b/pkg/ai/modelcatalog/sources.go
deleted file mode 100644
index 0236f9f9..00000000
--- a/pkg/ai/modelcatalog/sources.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package modelcatalog
-
-import (
- "encoding/json"
- "strconv"
-)
-
-type modelsDevResponse map[string]modelsDevProvider
-
-type modelsDevProvider struct {
- Models map[string]modelsDevModel `json:"models"`
-}
-
-type modelsDevModel struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Reasoning bool `json:"reasoning"`
- ToolCall bool `json:"tool_call"`
- Modalities modelsDevModalities `json:"modalities"`
- Cost modelsDevCost `json:"cost"`
- Limit modelsDevLimit `json:"limit"`
-}
-
-type modelsDevModalities struct {
- Input []string `json:"input"`
- Output []string `json:"output"`
-}
-
-type modelsDevCost struct {
- Input float64 `json:"input"`
- Output float64 `json:"output"`
- CacheRead float64 `json:"cache_read"`
- CacheWrite float64 `json:"cache_write"`
-}
-
-type modelsDevLimit struct {
- Context int `json:"context"`
- Output int `json:"output"`
-}
-
-type openRouterResponse struct {
- Data []openRouterModel `json:"data"`
-}
-
-type openRouterModel struct {
- ID string `json:"id"`
- Name string `json:"name"`
- SupportedParameters []string `json:"supported_parameters"`
- Architecture openRouterArchitecture `json:"architecture"`
- Pricing openRouterPricing `json:"pricing"`
- ContextLength int `json:"context_length"`
- TopProvider openRouterTopProvider `json:"top_provider"`
-}
-
-type openRouterArchitecture struct {
- InputModalities []string `json:"input_modalities"`
-}
-
-type openRouterPricing struct {
- Prompt float64 `json:"prompt"`
- Completion float64 `json:"completion"`
- InputCacheRead float64 `json:"input_cache_read"`
- InputCacheWrite float64 `json:"input_cache_write"`
-}
-
-type openRouterTopProvider struct {
- ContextLength int `json:"context_length"`
- MaxCompletionTokens int `json:"max_completion_tokens"`
-}
-
-func (pricing *openRouterPricing) UnmarshalJSON(data []byte) error {
- type rawPricing struct {
- Prompt number `json:"prompt"`
- Completion number `json:"completion"`
- InputCacheRead number `json:"input_cache_read"`
- InputCacheWrite number `json:"input_cache_write"`
- }
- var raw rawPricing
- if err := json.Unmarshal(data, &raw); err != nil {
- return err
- }
- pricing.Prompt = float64(raw.Prompt)
- pricing.Completion = float64(raw.Completion)
- pricing.InputCacheRead = float64(raw.InputCacheRead)
- pricing.InputCacheWrite = float64(raw.InputCacheWrite)
- return nil
-}
-
-type number float64
-
-func (n *number) UnmarshalJSON(data []byte) error {
- if len(data) == 0 || string(data) == "null" {
- *n = 0
- return nil
- }
- if data[0] == '"' {
- var text string
- if err := json.Unmarshal(data, &text); err != nil {
- return err
- }
- if text == "" {
- *n = 0
- return nil
- }
- value, err := strconv.ParseFloat(text, 64)
- if err != nil {
- return err
- }
- *n = number(value)
- return nil
- }
- var value float64
- if err := json.Unmarshal(data, &value); err != nil {
- return err
- }
- *n = number(value)
- return nil
-}
diff --git a/pkg/ai/models.go b/pkg/ai/models.go
index 01010779..13e77707 100644
--- a/pkg/ai/models.go
+++ b/pkg/ai/models.go
@@ -1,7 +1,5 @@
package ai
-import "slices"
-
var extendedThinkingLevels = []ModelThinkingLevel{
ModelThinkingLevelOff,
ModelThinkingLevelMinimal,
@@ -19,31 +17,6 @@ const (
ModelThinkingLevelXHigh ModelThinkingLevel = "xhigh"
)
-func GetModel(provider Provider, modelID string) (Model, bool) {
- models, ok := Models[provider]
- if !ok {
- return Model{}, false
- }
- model, ok := models[modelID]
- return model, ok
-}
-
-func GetProviders() []Provider {
- return slices.Clone(modelProviderOrder)
-}
-
-func GetModels(provider Provider) []Model {
- models := Models[provider]
- order := modelIDOrder[provider]
- out := make([]Model, 0, len(order))
- for _, modelID := range order {
- if model, ok := models[modelID]; ok {
- out = append(out, model)
- }
- }
- return out
-}
-
func GetSupportedThinkingLevels(model Model) []ModelThinkingLevel {
if !model.Reasoning {
return []ModelThinkingLevel{ModelThinkingLevelOff}
@@ -71,7 +44,7 @@ func GetSupportedThinkingLevels(model Model) []ModelThinkingLevel {
func ClampThinkingLevel(model Model, level ModelThinkingLevel) ModelThinkingLevel {
available := GetSupportedThinkingLevels(model)
- if slices.Contains(available, level) {
+ if containsThinkingLevel(available, level) {
return level
}
requestedIndex := thinkingLevelIndex(level)
@@ -80,13 +53,13 @@ func ClampThinkingLevel(model Model, level ModelThinkingLevel) ModelThinkingLeve
}
for i := requestedIndex; i < len(extendedThinkingLevels); i++ {
candidate := extendedThinkingLevels[i]
- if slices.Contains(available, candidate) {
+ if containsThinkingLevel(available, candidate) {
return candidate
}
}
for i := requestedIndex - 1; i >= 0; i-- {
candidate := extendedThinkingLevels[i]
- if slices.Contains(available, candidate) {
+ if containsThinkingLevel(available, candidate) {
return candidate
}
}
@@ -109,6 +82,15 @@ func thinkingLevelIndex(level ModelThinkingLevel) int {
return -1
}
+func containsThinkingLevel(levels []ModelThinkingLevel, level ModelThinkingLevel) bool {
+ for _, candidate := range levels {
+ if candidate == level {
+ return true
+ }
+ }
+ return false
+}
+
func firstThinkingLevelOrOff(levels []ModelThinkingLevel) ModelThinkingLevel {
if len(levels) == 0 {
return ModelThinkingLevelOff
diff --git a/pkg/ai/models_generated.go b/pkg/ai/models_generated.go
deleted file mode 100644
index 87a1cbdf..00000000
--- a/pkg/ai/models_generated.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package ai
-
-import "encoding/json"
-
-var modelsJSON = `{"openai":{"gpt-4":{"id":"gpt-4","name":"GPT-4","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text"],"cost":{"input":30,"output":60,"cacheRead":0,"cacheWrite":0},"contextWindow":8192,"maxTokens":8192},"gpt-4-turbo":{"id":"gpt-4-turbo","name":"GPT-4 Turbo","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":10,"output":30,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4096},"gpt-4.1":{"id":"gpt-4.1","name":"GPT-4.1","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":2,"output":8,"cacheRead":0.5,"cacheWrite":0},"contextWindow":1047576,"maxTokens":32768},"gpt-4.1-mini":{"id":"gpt-4.1-mini","name":"GPT-4.1 mini","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.4,"output":1.6,"cacheRead":0.1,"cacheWrite":0},"contextWindow":1047576,"maxTokens":32768},"gpt-4.1-nano":{"id":"gpt-4.1-nano","name":"GPT-4.1 nano","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.1,"output":0.4,"cacheRead":0.03,"cacheWrite":0},"contextWindow":1047576,"maxTokens":32768},"gpt-4o":{"id":"gpt-4o","name":"GPT-4o","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":10,"cacheRead":1.25,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-4o-2024-05-13":{"id":"gpt-4o-2024-05-13","name":"GPT-4o (2024-05-13)","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":5,"output":15,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4096},"gpt-4o-2024-08-06":{"id":"gpt-4o-2024-08-06","name":"GPT-4o (2024-08-06)","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":10,"cacheRead":1.25,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-4o-2024-11-20":{"id":"gpt-4o-2024-11-20","name":"GPT-4o (2024-11-20)","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":10,"cacheRead":1.25,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-4o-mini":{"id":"gpt-4o-mini","name":"GPT-4o mini","api":"openai-completions","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.15,"output":0.6,"cacheRead":0.08,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-5":{"id":"gpt-5","name":"GPT-5","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5-codex":{"id":"gpt-5-codex","name":"GPT-5-Codex","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5-mini":{"id":"gpt-5-mini","name":"GPT-5 Mini","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0.025,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5-nano":{"id":"gpt-5-nano","name":"GPT-5 Nano","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":0.05,"output":0.4,"cacheRead":0.005,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5-pro":{"id":"gpt-5-pro","name":"GPT-5 Pro","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":15,"output":120,"cacheRead":0,"cacheWrite":0},"contextWindow":400000,"maxTokens":272000},"gpt-5.1":{"id":"gpt-5.1","name":"GPT-5.1","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.13,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.1-chat-latest":{"id":"gpt-5.1-chat-latest","name":"GPT-5.1 Chat","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-5.1-codex":{"id":"gpt-5.1-codex","name":"GPT-5.1 Codex","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.1-codex-max":{"id":"gpt-5.1-codex-max","name":"GPT-5.1 Codex Max","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.1-codex-mini":{"id":"gpt-5.1-codex-mini","name":"GPT-5.1 Codex mini","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null},"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0.025,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.2":{"id":"gpt-5.2","name":"GPT-5.2","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.2-chat-latest":{"id":"gpt-5.2-chat-latest","name":"GPT-5.2 Chat","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-5.2-codex":{"id":"gpt-5.2-codex","name":"GPT-5.2 Codex","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.2-pro":{"id":"gpt-5.2-pro","name":"GPT-5.2 Pro","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":21,"output":168,"cacheRead":0,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.3-chat-latest":{"id":"gpt-5.3-chat-latest","name":"GPT-5.3 Chat (latest)","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":false,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"gpt-5.3-codex":{"id":"gpt-5.3-codex","name":"GPT-5.3 Codex","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.3-codex-spark":{"id":"gpt-5.3-codex-spark","name":"GPT-5.3 Codex Spark","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":128000,"maxTokens":32000},"gpt-5.4":{"id":"gpt-5.4","name":"GPT-5.4","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"gpt-5.4-mini":{"id":"gpt-5.4-mini","name":"GPT-5.4 mini","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.4-nano":{"id":"gpt-5.4-nano","name":"GPT-5.4 nano","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":0.2,"output":1.25,"cacheRead":0.02,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"gpt-5.4-pro":{"id":"gpt-5.4-pro","name":"GPT-5.4 Pro","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":30,"output":180,"cacheRead":0,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"gpt-5.5":{"id":"gpt-5.5","name":"GPT-5.5","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"gpt-5.5-pro":{"id":"gpt-5.5-pro","name":"GPT-5.5 Pro","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"thinkingLevelMap":{"off":null,"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":30,"output":180,"cacheRead":0,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"o1":{"id":"o1","name":"o1","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":15,"output":60,"cacheRead":7.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o1-pro":{"id":"o1-pro","name":"o1-pro","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":150,"output":600,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o3":{"id":"o3","name":"o3","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":8,"cacheRead":0.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o3-deep-research":{"id":"o3-deep-research","name":"o3-deep-research","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":10,"output":40,"cacheRead":2.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o3-mini":{"id":"o3-mini","name":"o3-mini","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text"],"cost":{"input":1.1,"output":4.4,"cacheRead":0.55,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o3-pro":{"id":"o3-pro","name":"o3-pro","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":20,"output":80,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o4-mini":{"id":"o4-mini","name":"o4-mini","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.1,"output":4.4,"cacheRead":0.28,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"o4-mini-deep-research":{"id":"o4-mini-deep-research","name":"o4-mini-deep-research","api":"openai-responses","provider":"openai","baseUrl":"https://api.openai.com/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":8,"cacheRead":0.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000}},"openrouter":{"ai21/jamba-large-1.7":{"id":"ai21/jamba-large-1.7","name":"AI21: Jamba Large 1.7","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2,"output":8,"cacheRead":0,"cacheWrite":0},"contextWindow":256000,"maxTokens":4096},"alibaba/tongyi-deepresearch-30b-a3b":{"id":"alibaba/tongyi-deepresearch-30b-a3b","name":"Tongyi DeepResearch 30B A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09,"output":0.44999999999999996,"cacheRead":0.09,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"amazon/nova-2-lite-v1":{"id":"amazon/nova-2-lite-v1","name":"Amazon: Nova 2 Lite","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.3,"output":2.5,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":65535},"amazon/nova-lite-v1":{"id":"amazon/nova-lite-v1","name":"Amazon: Nova Lite 1.0","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.06,"output":0.24,"cacheRead":0,"cacheWrite":0},"contextWindow":300000,"maxTokens":5120},"amazon/nova-micro-v1":{"id":"amazon/nova-micro-v1","name":"Amazon: Nova Micro 1.0","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.035,"output":0.14,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":5120},"amazon/nova-premier-v1":{"id":"amazon/nova-premier-v1","name":"Amazon: Nova Premier 1.0","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":12.5,"cacheRead":0.625,"cacheWrite":0},"contextWindow":1000000,"maxTokens":32000},"amazon/nova-pro-v1":{"id":"amazon/nova-pro-v1","name":"Amazon: Nova Pro 1.0","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.7999999999999999,"output":3.1999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":300000,"maxTokens":5120},"anthropic/claude-3-haiku":{"id":"anthropic/claude-3-haiku","name":"Anthropic: Claude 3 Haiku","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.25,"output":1.25,"cacheRead":0.03,"cacheWrite":0.3},"contextWindow":200000,"maxTokens":4096,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-3.5-haiku":{"id":"anthropic/claude-3.5-haiku","name":"Anthropic: Claude 3.5 Haiku","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.7999999999999999,"output":4,"cacheRead":0.08,"cacheWrite":1},"contextWindow":200000,"maxTokens":8192,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-haiku-4.5":{"id":"anthropic/claude-haiku-4.5","name":"Anthropic: Claude Haiku 4.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":5,"cacheRead":0.09999999999999999,"cacheWrite":1.25},"contextWindow":200000,"maxTokens":64000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4":{"id":"anthropic/claude-opus-4","name":"Anthropic: Claude Opus 4","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":15,"output":75,"cacheRead":1.5,"cacheWrite":18.75},"contextWindow":200000,"maxTokens":32000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4.1":{"id":"anthropic/claude-opus-4.1","name":"Anthropic: Claude Opus 4.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":15,"output":75,"cacheRead":1.5,"cacheWrite":18.75},"contextWindow":200000,"maxTokens":32000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4.5":{"id":"anthropic/claude-opus-4.5","name":"Anthropic: Claude Opus 4.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":200000,"maxTokens":64000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4.6":{"id":"anthropic/claude-opus-4.6","name":"Anthropic: Claude Opus 4.6","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4.6-fast":{"id":"anthropic/claude-opus-4.6-fast","name":"Anthropic: Claude Opus 4.6 (Fast)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":30,"output":150,"cacheRead":3,"cacheWrite":37.5},"contextWindow":1000000,"maxTokens":128000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4.7":{"id":"anthropic/claude-opus-4.7","name":"Anthropic: Claude Opus 4.7","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-opus-4.7-fast":{"id":"anthropic/claude-opus-4.7-fast","name":"Anthropic: Claude Opus 4.7 (Fast)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":30,"output":150,"cacheRead":3,"cacheWrite":37.5},"contextWindow":1000000,"maxTokens":128000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-sonnet-4":{"id":"anthropic/claude-sonnet-4","name":"Anthropic: Claude Sonnet 4","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":1000000,"maxTokens":64000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-sonnet-4.5":{"id":"anthropic/claude-sonnet-4.5","name":"Anthropic: Claude Sonnet 4.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":1000000,"maxTokens":64000,"compat":{"cacheControlFormat":"anthropic"}},"anthropic/claude-sonnet-4.6":{"id":"anthropic/claude-sonnet-4.6","name":"Anthropic: Claude Sonnet 4.6","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":1000000,"maxTokens":128000,"compat":{"cacheControlFormat":"anthropic"}},"arcee-ai/trinity-large-thinking":{"id":"arcee-ai/trinity-large-thinking","name":"Arcee AI: Trinity Large Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.22,"output":0.85,"cacheRead":0.06,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"arcee-ai/trinity-large-thinking:free":{"id":"arcee-ai/trinity-large-thinking:free","name":"Arcee AI: Trinity Large Thinking (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":80000},"arcee-ai/trinity-mini":{"id":"arcee-ai/trinity-mini","name":"Arcee AI: Trinity Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.045,"output":0.15,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"arcee-ai/virtuoso-large":{"id":"arcee-ai/virtuoso-large","name":"Arcee AI: Virtuoso Large","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.75,"output":1.2,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":64000},"baidu/cobuddy:free":{"id":"baidu/cobuddy:free","name":"Baidu Qianfan: CoBuddy (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":65536},"baidu/ernie-4.5-21b-a3b":{"id":"baidu/ernie-4.5-21b-a3b","name":"Baidu: ERNIE 4.5 21B A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.07,"output":0.28,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":8000},"baidu/ernie-4.5-vl-28b-a3b":{"id":"baidu/ernie-4.5-vl-28b-a3b","name":"Baidu: ERNIE 4.5 VL 28B A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.14,"output":0.56,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":8000},"bytedance-seed/seed-1.6":{"id":"bytedance-seed/seed-1.6","name":"ByteDance Seed: Seed 1.6","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"bytedance-seed/seed-1.6-flash":{"id":"bytedance-seed/seed-1.6-flash","name":"ByteDance Seed: Seed 1.6 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.075,"output":0.3,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"bytedance-seed/seed-2.0-lite":{"id":"bytedance-seed/seed-2.0-lite","name":"ByteDance Seed: Seed-2.0-Lite","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":131072},"bytedance-seed/seed-2.0-mini":{"id":"bytedance-seed/seed-2.0-mini","name":"ByteDance Seed: Seed-2.0-Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.39999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":131072},"cohere/command-r-08-2024":{"id":"cohere/command-r-08-2024","name":"Cohere: Command R (08-2024)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.15,"output":0.6,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4000},"cohere/command-r-plus-08-2024":{"id":"cohere/command-r-plus-08-2024","name":"Cohere: Command R+ (08-2024)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2.5,"output":10,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4000},"deepseek/deepseek-chat":{"id":"deepseek/deepseek-chat","name":"DeepSeek: DeepSeek V3","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.32,"output":0.8899999999999999,"cacheRead":0,"cacheWrite":0},"contextWindow":163840,"maxTokens":16384},"deepseek/deepseek-chat-v3-0324":{"id":"deepseek/deepseek-chat-v3-0324","name":"DeepSeek: DeepSeek V3 0324","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.19999999999999998,"output":0.77,"cacheRead":0.135,"cacheWrite":0},"contextWindow":163840,"maxTokens":16384},"deepseek/deepseek-chat-v3.1":{"id":"deepseek/deepseek-chat-v3.1","name":"DeepSeek: DeepSeek V3.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.21,"output":0.7899999999999999,"cacheRead":0.13,"cacheWrite":0},"contextWindow":163840,"maxTokens":32768},"deepseek/deepseek-r1":{"id":"deepseek/deepseek-r1","name":"DeepSeek: R1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.7,"output":2.5,"cacheRead":0,"cacheWrite":0},"contextWindow":163840,"maxTokens":16000},"deepseek/deepseek-r1-0528":{"id":"deepseek/deepseek-r1-0528","name":"DeepSeek: R1 0528","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.5,"output":2.1500000000000004,"cacheRead":0.35,"cacheWrite":0},"contextWindow":163840,"maxTokens":32768},"deepseek/deepseek-v3.1-terminus":{"id":"deepseek/deepseek-v3.1-terminus","name":"DeepSeek: DeepSeek V3.1 Terminus","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.27,"output":0.95,"cacheRead":0.13,"cacheWrite":0},"contextWindow":163840,"maxTokens":32768},"deepseek/deepseek-v3.2":{"id":"deepseek/deepseek-v3.2","name":"DeepSeek: DeepSeek V3.2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.252,"output":0.378,"cacheRead":0.0252,"cacheWrite":0},"contextWindow":131072,"maxTokens":65536},"deepseek/deepseek-v3.2-exp":{"id":"deepseek/deepseek-v3.2-exp","name":"DeepSeek: DeepSeek V3.2 Exp","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.27,"output":0.41,"cacheRead":0,"cacheWrite":0},"contextWindow":163840,"maxTokens":65536},"deepseek/deepseek-v4-flash":{"id":"deepseek/deepseek-v4-flash","name":"DeepSeek: DeepSeek V4 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.19999999999999998,"cacheRead":0.02,"cacheWrite":0},"contextWindow":1048576,"maxTokens":16384},"deepseek/deepseek-v4-flash:free":{"id":"deepseek/deepseek-v4-flash:free","name":"DeepSeek: DeepSeek V4 Flash (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":384000},"deepseek/deepseek-v4-pro":{"id":"deepseek/deepseek-v4-pro","name":"DeepSeek: DeepSeek V4 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.435,"output":0.87,"cacheRead":0.003625,"cacheWrite":0},"contextWindow":1048576,"maxTokens":384000},"essentialai/rnj-1-instruct":{"id":"essentialai/rnj-1-instruct","name":"EssentialAI: Rnj 1 Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.15,"output":0.15,"cacheRead":0,"cacheWrite":0},"contextWindow":32768,"maxTokens":16384},"google/gemini-2.0-flash-001":{"id":"google/gemini-2.0-flash-001","name":"Google: Gemini 2.0 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.39999999999999997,"cacheRead":0.024999999999999998,"cacheWrite":0.08333333333333334},"contextWindow":1000000,"maxTokens":8192},"google/gemini-2.0-flash-lite-001":{"id":"google/gemini-2.0-flash-lite-001","name":"Google: Gemini 2.0 Flash Lite","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.075,"output":0.3,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":8192},"google/gemini-2.5-flash":{"id":"google/gemini-2.5-flash","name":"Google: Gemini 2.5 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.3,"output":2.5,"cacheRead":0.03,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65535},"google/gemini-2.5-flash-lite":{"id":"google/gemini-2.5-flash-lite","name":"Google: Gemini 2.5 Flash Lite","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.39999999999999997,"cacheRead":0.01,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65535},"google/gemini-2.5-flash-lite-preview-09-2025":{"id":"google/gemini-2.5-flash-lite-preview-09-2025","name":"Google: Gemini 2.5 Flash Lite Preview 09-2025","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.39999999999999997,"cacheRead":0.01,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65535},"google/gemini-2.5-pro":{"id":"google/gemini-2.5-pro","name":"Google: Gemini 2.5 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0.375},"contextWindow":1048576,"maxTokens":65536},"google/gemini-2.5-pro-preview":{"id":"google/gemini-2.5-pro-preview","name":"Google: Gemini 2.5 Pro Preview 06-05","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0.375},"contextWindow":1048576,"maxTokens":65536},"google/gemini-2.5-pro-preview-05-06":{"id":"google/gemini-2.5-pro-preview-05-06","name":"Google: Gemini 2.5 Pro Preview 05-06","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0.375},"contextWindow":1048576,"maxTokens":65535},"google/gemini-3-flash-preview":{"id":"google/gemini-3-flash-preview","name":"Google: Gemini 3 Flash Preview","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.5,"output":3,"cacheRead":0.049999999999999996,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65536},"google/gemini-3.1-flash-lite":{"id":"google/gemini-3.1-flash-lite","name":"Google: Gemini 3.1 Flash Lite","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":1.5,"cacheRead":0.024999999999999998,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65536},"google/gemini-3.1-flash-lite-preview":{"id":"google/gemini-3.1-flash-lite-preview","name":"Google: Gemini 3.1 Flash Lite Preview","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":1.5,"cacheRead":0.024999999999999998,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65536},"google/gemini-3.1-pro-preview":{"id":"google/gemini-3.1-pro-preview","name":"Google: Gemini 3.1 Pro Preview","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":12,"cacheRead":0.19999999999999998,"cacheWrite":0.375},"contextWindow":1048576,"maxTokens":65536},"google/gemini-3.1-pro-preview-customtools":{"id":"google/gemini-3.1-pro-preview-customtools","name":"Google: Gemini 3.1 Pro Preview Custom Tools","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":12,"cacheRead":0.19999999999999998,"cacheWrite":0.375},"contextWindow":1048756,"maxTokens":65536},"google/gemini-3.5-flash":{"id":"google/gemini-3.5-flash","name":"Google: Gemini 3.5 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.5,"output":9,"cacheRead":0.15,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65536},"google/gemma-3-12b-it":{"id":"google/gemma-3-12b-it","name":"Google: Gemma 3 12B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.04,"output":0.13,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"google/gemma-3-27b-it":{"id":"google/gemma-3-27b-it","name":"Google: Gemma 3 27B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.08,"output":0.16,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"google/gemma-4-26b-a4b-it":{"id":"google/gemma-4-26b-a4b-it","name":"Google: Gemma 4 26B A4B ","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.06,"output":0.33,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"google/gemma-4-26b-a4b-it:free":{"id":"google/gemma-4-26b-a4b-it:free","name":"Google: Gemma 4 26B A4B (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"google/gemma-4-31b-it":{"id":"google/gemma-4-31b-it","name":"Google: Gemma 4 31B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.12,"output":0.37,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"google/gemma-4-31b-it:free":{"id":"google/gemma-4-31b-it:free","name":"Google: Gemma 4 31B (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"ibm-granite/granite-4.1-8b":{"id":"ibm-granite/granite-4.1-8b","name":"IBM: Granite 4.1 8B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.049999999999999996,"output":0.09999999999999999,"cacheRead":0.049999999999999996,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"inception/mercury-2":{"id":"inception/mercury-2","name":"Inception: Mercury 2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.25,"output":0.75,"cacheRead":0.024999999999999998,"cacheWrite":0},"contextWindow":128000,"maxTokens":50000},"inclusionai/ling-2.6-1t":{"id":"inclusionai/ling-2.6-1t","name":"inclusionAI: Ling-2.6-1T","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.075,"output":0.625,"cacheRead":0.015,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"inclusionai/ling-2.6-flash":{"id":"inclusionai/ling-2.6-flash","name":"inclusionAI: Ling-2.6-flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.01,"output":0.03,"cacheRead":0.002,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"inclusionai/ring-2.6-1t":{"id":"inclusionai/ring-2.6-1t","name":"inclusionAI: Ring-2.6-1T","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.075,"output":0.625,"cacheRead":0.015,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536},"kwaipilot/kat-coder-pro-v2":{"id":"kwaipilot/kat-coder-pro-v2","name":"Kwaipilot: KAT-Coder-Pro V2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.3,"output":1.2,"cacheRead":0.06,"cacheWrite":0},"contextWindow":256000,"maxTokens":80000},"meta-llama/llama-3.1-70b-instruct":{"id":"meta-llama/llama-3.1-70b-instruct","name":"Meta: Llama 3.1 70B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.39999999999999997,"output":0.39999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"meta-llama/llama-3.1-8b-instruct":{"id":"meta-llama/llama-3.1-8b-instruct","name":"Meta: Llama 3.1 8B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.02,"output":0.049999999999999996,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"meta-llama/llama-3.3-70b-instruct":{"id":"meta-llama/llama-3.3-70b-instruct","name":"Meta: Llama 3.3 70B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.32,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"meta-llama/llama-3.3-70b-instruct:free":{"id":"meta-llama/llama-3.3-70b-instruct:free","name":"Meta: Llama 3.3 70B Instruct (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"meta-llama/llama-4-scout":{"id":"meta-llama/llama-4-scout","name":"Meta: Llama 4 Scout","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.08,"output":0.3,"cacheRead":0,"cacheWrite":0},"contextWindow":10000000,"maxTokens":16384},"minimax/minimax-m1":{"id":"minimax/minimax-m1","name":"MiniMax: MiniMax M1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.39999999999999997,"output":2.2,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":40000},"minimax/minimax-m2":{"id":"minimax/minimax-m2","name":"MiniMax: MiniMax M2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.255,"output":1,"cacheRead":0.03,"cacheWrite":0},"contextWindow":204800,"maxTokens":196608},"minimax/minimax-m2.1":{"id":"minimax/minimax-m2.1","name":"MiniMax: MiniMax M2.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.29,"output":0.95,"cacheRead":0.03,"cacheWrite":0},"contextWindow":204800,"maxTokens":196608},"minimax/minimax-m2.5":{"id":"minimax/minimax-m2.5","name":"MiniMax: MiniMax M2.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.15,"output":1.15,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":196608},"minimax/minimax-m2.5:free":{"id":"minimax/minimax-m2.5:free","name":"MiniMax: MiniMax M2.5 (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":8192},"minimax/minimax-m2.7":{"id":"minimax/minimax-m2.7","name":"MiniMax: MiniMax M2.7","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.27899999999999997,"output":1.2,"cacheRead":0,"cacheWrite":0},"contextWindow":204800,"maxTokens":131072},"mistralai/codestral-2508":{"id":"mistralai/codestral-2508","name":"Mistral: Codestral 2508","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.3,"output":0.8999999999999999,"cacheRead":0.03,"cacheWrite":0},"contextWindow":256000,"maxTokens":16384},"mistralai/devstral-2512":{"id":"mistralai/devstral-2512","name":"Mistral: Devstral 2 2512","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.39999999999999997,"output":2,"cacheRead":0.04,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"mistralai/devstral-medium":{"id":"mistralai/devstral-medium","name":"Mistral: Devstral Medium","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.39999999999999997,"output":2,"cacheRead":0.04,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/devstral-small":{"id":"mistralai/devstral-small","name":"Mistral: Devstral Small 1.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.3,"cacheRead":0.01,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/ministral-14b-2512":{"id":"mistralai/ministral-14b-2512","name":"Mistral: Ministral 3 14B 2512","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.19999999999999998,"output":0.19999999999999998,"cacheRead":0.02,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"mistralai/ministral-3b-2512":{"id":"mistralai/ministral-3b-2512","name":"Mistral: Ministral 3 3B 2512","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.09999999999999999,"cacheRead":0.01,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/ministral-8b-2512":{"id":"mistralai/ministral-8b-2512","name":"Mistral: Ministral 3 8B 2512","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.15,"output":0.15,"cacheRead":0.015,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"mistralai/mistral-large":{"id":"mistralai/mistral-large","name":"Mistral Large","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2,"output":6,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"mistralai/mistral-large-2407":{"id":"mistralai/mistral-large-2407","name":"Mistral Large 2407","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2,"output":6,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/mistral-large-2411":{"id":"mistralai/mistral-large-2411","name":"Mistral Large 2411","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2,"output":6,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/mistral-large-2512":{"id":"mistralai/mistral-large-2512","name":"Mistral: Mistral Large 3 2512","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.5,"output":1.5,"cacheRead":0.049999999999999996,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"mistralai/mistral-medium-3":{"id":"mistralai/mistral-medium-3","name":"Mistral: Mistral Medium 3","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.39999999999999997,"output":2,"cacheRead":0.04,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/mistral-medium-3-5":{"id":"mistralai/mistral-medium-3-5","name":"Mistral: Mistral Medium 3.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.5,"output":7.5,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"mistralai/mistral-medium-3.1":{"id":"mistralai/mistral-medium-3.1","name":"Mistral: Mistral Medium 3.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.39999999999999997,"output":2,"cacheRead":0.04,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/mistral-nemo":{"id":"mistralai/mistral-nemo","name":"Mistral: Mistral Nemo","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.02,"output":0.03,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/mistral-saba":{"id":"mistralai/mistral-saba","name":"Mistral: Saba","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.19999999999999998,"output":0.6,"cacheRead":0.02,"cacheWrite":0},"contextWindow":32768,"maxTokens":16384},"mistralai/mistral-small-2603":{"id":"mistralai/mistral-small-2603","name":"Mistral: Mistral Small 4","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.15,"output":0.6,"cacheRead":0.015,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"mistralai/mistral-small-3.2-24b-instruct":{"id":"mistralai/mistral-small-3.2-24b-instruct","name":"Mistral: Mistral Small 3.2 24B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.075,"output":0.19999999999999998,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"mistralai/mixtral-8x22b-instruct":{"id":"mistralai/mixtral-8x22b-instruct","name":"Mistral: Mixtral 8x22B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2,"output":6,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":65536,"maxTokens":16384},"mistralai/pixtral-large-2411":{"id":"mistralai/pixtral-large-2411","name":"Mistral: Pixtral Large 2411","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":2,"output":6,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"mistralai/voxtral-small-24b-2507":{"id":"mistralai/voxtral-small-24b-2507","name":"Mistral: Voxtral Small 24B 2507","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.3,"cacheRead":0.01,"cacheWrite":0},"contextWindow":32000,"maxTokens":16384},"moonshotai/kimi-k2":{"id":"moonshotai/kimi-k2","name":"MoonshotAI: Kimi K2 0711","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.5700000000000001,"output":2.3,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":32768},"moonshotai/kimi-k2-0905":{"id":"moonshotai/kimi-k2-0905","name":"MoonshotAI: Kimi K2 0905","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.6,"output":2.5,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"moonshotai/kimi-k2-thinking":{"id":"moonshotai/kimi-k2-thinking","name":"MoonshotAI: Kimi K2 Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.6,"output":2.5,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"moonshotai/kimi-k2.5":{"id":"moonshotai/kimi-k2.5","name":"MoonshotAI: Kimi K2.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.39999999999999997,"output":1.9,"cacheRead":0.09,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"moonshotai/kimi-k2.6":{"id":"moonshotai/kimi-k2.6","name":"MoonshotAI: Kimi K2.6","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.73,"output":3.49,"cacheRead":0.25,"cacheWrite":0},"contextWindow":262144,"maxTokens":262142},"nex-agi/deepseek-v3.1-nex-n1":{"id":"nex-agi/deepseek-v3.1-nex-n1","name":"Nex AGI: DeepSeek V3.1 Nex N1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.135,"output":0.5,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":163840},"nvidia/llama-3.3-nemotron-super-49b-v1.5":{"id":"nvidia/llama-3.3-nemotron-super-49b-v1.5","name":"NVIDIA: Llama 3.3 Nemotron Super 49B V1.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.39999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"nvidia/nemotron-3-nano-30b-a3b":{"id":"nvidia/nemotron-3-nano-30b-a3b","name":"NVIDIA: Nemotron 3 Nano 30B A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.049999999999999996,"output":0.19999999999999998,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":228000},"nvidia/nemotron-3-nano-30b-a3b:free":{"id":"nvidia/nemotron-3-nano-30b-a3b:free","name":"NVIDIA: Nemotron 3 Nano 30B A3B (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":256000,"maxTokens":16384},"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free":{"id":"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free","name":"NVIDIA: Nemotron 3 Nano Omni (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":256000,"maxTokens":65536},"nvidia/nemotron-3-super-120b-a12b":{"id":"nvidia/nemotron-3-super-120b-a12b","name":"NVIDIA: Nemotron 3 Super","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09,"output":0.44999999999999996,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":16384},"nvidia/nemotron-3-super-120b-a12b:free":{"id":"nvidia/nemotron-3-super-120b-a12b:free","name":"NVIDIA: Nemotron 3 Super (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":262144},"nvidia/nemotron-nano-12b-v2-vl:free":{"id":"nvidia/nemotron-nano-12b-v2-vl:free","name":"NVIDIA: Nemotron Nano 12B 2 VL (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":128000},"nvidia/nemotron-nano-9b-v2":{"id":"nvidia/nemotron-nano-9b-v2","name":"NVIDIA: Nemotron Nano 9B V2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.04,"output":0.16,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"nvidia/nemotron-nano-9b-v2:free":{"id":"nvidia/nemotron-nano-9b-v2:free","name":"NVIDIA: Nemotron Nano 9B V2 (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-3.5-turbo":{"id":"openai/gpt-3.5-turbo","name":"OpenAI: GPT-3.5 Turbo","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.5,"output":1.5,"cacheRead":0,"cacheWrite":0},"contextWindow":16385,"maxTokens":4096},"openai/gpt-3.5-turbo-0613":{"id":"openai/gpt-3.5-turbo-0613","name":"OpenAI: GPT-3.5 Turbo (older v0613)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":1,"output":2,"cacheRead":0,"cacheWrite":0},"contextWindow":4095,"maxTokens":4096},"openai/gpt-3.5-turbo-16k":{"id":"openai/gpt-3.5-turbo-16k","name":"OpenAI: GPT-3.5 Turbo 16k","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":3,"output":4,"cacheRead":0,"cacheWrite":0},"contextWindow":16385,"maxTokens":4096},"openai/gpt-4":{"id":"openai/gpt-4","name":"OpenAI: GPT-4","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":30,"output":60,"cacheRead":0,"cacheWrite":0},"contextWindow":8191,"maxTokens":4096},"openai/gpt-4-0314":{"id":"openai/gpt-4-0314","name":"OpenAI: GPT-4 (older v0314)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":30,"output":60,"cacheRead":0,"cacheWrite":0},"contextWindow":8191,"maxTokens":4096},"openai/gpt-4-1106-preview":{"id":"openai/gpt-4-1106-preview","name":"OpenAI: GPT-4 Turbo (older v1106)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":10,"output":30,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4096},"openai/gpt-4-turbo":{"id":"openai/gpt-4-turbo","name":"OpenAI: GPT-4 Turbo","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":10,"output":30,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4096},"openai/gpt-4-turbo-preview":{"id":"openai/gpt-4-turbo-preview","name":"OpenAI: GPT-4 Turbo Preview","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":10,"output":30,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4096},"openai/gpt-4.1":{"id":"openai/gpt-4.1","name":"OpenAI: GPT-4.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":2,"output":8,"cacheRead":0.5,"cacheWrite":0},"contextWindow":1047576,"maxTokens":16384},"openai/gpt-4.1-mini":{"id":"openai/gpt-4.1-mini","name":"OpenAI: GPT-4.1 Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.39999999999999997,"output":1.5999999999999999,"cacheRead":0.09999999999999999,"cacheWrite":0},"contextWindow":1047576,"maxTokens":32768},"openai/gpt-4.1-nano":{"id":"openai/gpt-4.1-nano","name":"OpenAI: GPT-4.1 Nano","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.39999999999999997,"cacheRead":0.024999999999999998,"cacheWrite":0},"contextWindow":1047576,"maxTokens":32768},"openai/gpt-4o":{"id":"openai/gpt-4o","name":"OpenAI: GPT-4o","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":10,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-4o-2024-05-13":{"id":"openai/gpt-4o-2024-05-13","name":"OpenAI: GPT-4o (2024-05-13)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":5,"output":15,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":4096},"openai/gpt-4o-2024-08-06":{"id":"openai/gpt-4o-2024-08-06","name":"OpenAI: GPT-4o (2024-08-06)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":10,"cacheRead":1.25,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-4o-2024-11-20":{"id":"openai/gpt-4o-2024-11-20","name":"OpenAI: GPT-4o (2024-11-20)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":2.5,"output":10,"cacheRead":1.25,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-4o-audio-preview":{"id":"openai/gpt-4o-audio-preview","name":"OpenAI: GPT-4o Audio","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2.5,"output":10,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-4o-mini":{"id":"openai/gpt-4o-mini","name":"OpenAI: GPT-4o-mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.15,"output":0.6,"cacheRead":0.075,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-4o-mini-2024-07-18":{"id":"openai/gpt-4o-mini-2024-07-18","name":"OpenAI: GPT-4o-mini (2024-07-18)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.15,"output":0.6,"cacheRead":0.075,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-5":{"id":"openai/gpt-5","name":"OpenAI: GPT-5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5-codex":{"id":"openai/gpt-5-codex","name":"OpenAI: GPT-5 Codex","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5-mini":{"id":"openai/gpt-5-mini","name":"OpenAI: GPT-5 Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0.024999999999999998,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5-nano":{"id":"openai/gpt-5-nano","name":"OpenAI: GPT-5 Nano","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.049999999999999996,"output":0.39999999999999997,"cacheRead":0.01,"cacheWrite":0},"contextWindow":400000,"maxTokens":16384},"openai/gpt-5-pro":{"id":"openai/gpt-5-pro","name":"OpenAI: GPT-5 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":15,"output":120,"cacheRead":0,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.1":{"id":"openai/gpt-5.1","name":"OpenAI: GPT-5.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.13,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.1-chat":{"id":"openai/gpt-5.1-chat","name":"OpenAI: GPT-5.1 Chat","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-5.1-codex":{"id":"openai/gpt-5.1-codex","name":"OpenAI: GPT-5.1-Codex","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.1-codex-max":{"id":"openai/gpt-5.1-codex-max","name":"OpenAI: GPT-5.1-Codex-Max","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":10,"cacheRead":0.125,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.1-codex-mini":{"id":"openai/gpt-5.1-codex-mini","name":"OpenAI: GPT-5.1-Codex-Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.25,"output":2,"cacheRead":0.03,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.2":{"id":"openai/gpt-5.2","name":"OpenAI: GPT-5.2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.2-chat":{"id":"openai/gpt-5.2-chat","name":"OpenAI: GPT-5.2 Chat","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":128000,"maxTokens":32000},"openai/gpt-5.2-codex":{"id":"openai/gpt-5.2-codex","name":"OpenAI: GPT-5.2-Codex","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.2-pro":{"id":"openai/gpt-5.2-pro","name":"OpenAI: GPT-5.2 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":21,"output":168,"cacheRead":0,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.3-chat":{"id":"openai/gpt-5.3-chat","name":"OpenAI: GPT-5.3 Chat","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-5.3-codex":{"id":"openai/gpt-5.3-codex","name":"OpenAI: GPT-5.3-Codex","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":1.75,"output":14,"cacheRead":0.175,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.4":{"id":"openai/gpt-5.4","name":"OpenAI: GPT-5.4","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"openai/gpt-5.4-mini":{"id":"openai/gpt-5.4-mini","name":"OpenAI: GPT-5.4 Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.4-nano":{"id":"openai/gpt-5.4-nano","name":"OpenAI: GPT-5.4 Nano","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":0.19999999999999998,"output":1.25,"cacheRead":0.02,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-5.4-pro":{"id":"openai/gpt-5.4-pro","name":"OpenAI: GPT-5.4 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":30,"output":180,"cacheRead":0,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"openai/gpt-5.5":{"id":"openai/gpt-5.5","name":"OpenAI: GPT-5.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"openai/gpt-5.5-pro":{"id":"openai/gpt-5.5-pro","name":"OpenAI: GPT-5.5 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"thinkingLevelMap":{"xhigh":"xhigh"},"input":["text","image"],"cost":{"input":30,"output":180,"cacheRead":0,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"openai/gpt-audio":{"id":"openai/gpt-audio","name":"OpenAI: GPT Audio","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":2.5,"output":10,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-audio-mini":{"id":"openai/gpt-audio-mini","name":"OpenAI: GPT Audio Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.6,"output":2.4,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"openai/gpt-chat-latest":{"id":"openai/gpt-chat-latest","name":"OpenAI: GPT Chat Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000},"openai/gpt-oss-120b":{"id":"openai/gpt-oss-120b","name":"OpenAI: gpt-oss-120b","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.039,"output":0.18,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"openai/gpt-oss-120b:free":{"id":"openai/gpt-oss-120b:free","name":"OpenAI: gpt-oss-120b (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"openai/gpt-oss-20b":{"id":"openai/gpt-oss-20b","name":"OpenAI: gpt-oss-20b","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.03,"output":0.14,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"openai/gpt-oss-20b:free":{"id":"openai/gpt-oss-20b:free","name":"OpenAI: gpt-oss-20b (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":8192},"openai/gpt-oss-safeguard-20b":{"id":"openai/gpt-oss-safeguard-20b","name":"OpenAI: gpt-oss-safeguard-20b","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.075,"output":0.3,"cacheRead":0.037,"cacheWrite":0},"contextWindow":131072,"maxTokens":65536},"openai/o1":{"id":"openai/o1","name":"OpenAI: o1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":15,"output":60,"cacheRead":7.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o3":{"id":"openai/o3","name":"OpenAI: o3","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":8,"cacheRead":0.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o3-deep-research":{"id":"openai/o3-deep-research","name":"OpenAI: o3 Deep Research","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":10,"output":40,"cacheRead":2.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o3-mini":{"id":"openai/o3-mini","name":"OpenAI: o3 Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":1.1,"output":4.4,"cacheRead":0.55,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o3-mini-high":{"id":"openai/o3-mini-high","name":"OpenAI: o3 Mini High","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":1.1,"output":4.4,"cacheRead":0.55,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o3-pro":{"id":"openai/o3-pro","name":"OpenAI: o3 Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":20,"output":80,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o4-mini":{"id":"openai/o4-mini","name":"OpenAI: o4 Mini","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.1,"output":4.4,"cacheRead":0.275,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o4-mini-deep-research":{"id":"openai/o4-mini-deep-research","name":"OpenAI: o4 Mini Deep Research","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":8,"cacheRead":0.5,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openai/o4-mini-high":{"id":"openai/o4-mini-high","name":"OpenAI: o4 Mini High","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.1,"output":4.4,"cacheRead":0.275,"cacheWrite":0},"contextWindow":200000,"maxTokens":100000},"openrouter/auto":{"id":"openrouter/auto","name":"Auto Router","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":-1000000,"output":-1000000,"cacheRead":0,"cacheWrite":0},"contextWindow":2000000,"maxTokens":16384},"openrouter/free":{"id":"openrouter/free","name":"Free Models Router","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":200000,"maxTokens":16384},"openrouter/owl-alpha":{"id":"openrouter/owl-alpha","name":"Owl Alpha","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048756,"maxTokens":262144},"poolside/laguna-m.1:free":{"id":"poolside/laguna-m.1:free","name":"Poolside: Laguna M.1 (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":8192},"poolside/laguna-xs.2:free":{"id":"poolside/laguna-xs.2:free","name":"Poolside: Laguna XS.2 (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":8192},"prime-intellect/intellect-3":{"id":"prime-intellect/intellect-3","name":"Prime Intellect: INTELLECT-3","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.19999999999999998,"output":1.1,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"qwen/qwen-2.5-72b-instruct":{"id":"qwen/qwen-2.5-72b-instruct","name":"Qwen2.5 72B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.36,"output":0.39999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"qwen/qwen-2.5-7b-instruct":{"id":"qwen/qwen-2.5-7b-instruct","name":"Qwen: Qwen2.5 7B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.04,"output":0.09999999999999999,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":32768},"qwen/qwen-plus":{"id":"qwen/qwen-plus","name":"Qwen: Qwen-Plus","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.26,"output":0.78,"cacheRead":0.052000000000000005,"cacheWrite":0.325},"contextWindow":1000000,"maxTokens":32768},"qwen/qwen-plus-2025-07-28":{"id":"qwen/qwen-plus-2025-07-28","name":"Qwen: Qwen Plus 0728","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.26,"output":0.78,"cacheRead":0,"cacheWrite":0.325},"contextWindow":1000000,"maxTokens":32768},"qwen/qwen-plus-2025-07-28:thinking":{"id":"qwen/qwen-plus-2025-07-28:thinking","name":"Qwen: Qwen Plus 0728 (thinking)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.26,"output":0.78,"cacheRead":0,"cacheWrite":0.325},"contextWindow":1000000,"maxTokens":32768},"qwen/qwen3-14b":{"id":"qwen/qwen3-14b","name":"Qwen: Qwen3 14B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.24,"cacheRead":0,"cacheWrite":0},"contextWindow":131702,"maxTokens":40960},"qwen/qwen3-235b-a22b":{"id":"qwen/qwen3-235b-a22b","name":"Qwen: Qwen3 235B A22B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.45499999999999996,"output":1.8199999999999998,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":8192},"qwen/qwen3-235b-a22b-2507":{"id":"qwen/qwen3-235b-a22b-2507","name":"Qwen: Qwen3 235B A22B Instruct 2507","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.071,"output":0.09999999999999999,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"qwen/qwen3-235b-a22b-thinking-2507":{"id":"qwen/qwen3-235b-a22b-thinking-2507","name":"Qwen: Qwen3 235B A22B Thinking 2507","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.14950000000000002,"output":1.495,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"qwen/qwen3-30b-a3b":{"id":"qwen/qwen3-30b-a3b","name":"Qwen: Qwen3 30B A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09,"output":0.44999999999999996,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":20000},"qwen/qwen3-30b-a3b-instruct-2507":{"id":"qwen/qwen3-30b-a3b-instruct-2507","name":"Qwen: Qwen3 30B A3B Instruct 2507","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.09,"output":0.3,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"qwen/qwen3-30b-a3b-thinking-2507":{"id":"qwen/qwen3-30b-a3b-thinking-2507","name":"Qwen: Qwen3 30B A3B Thinking 2507","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.08,"output":0.39999999999999997,"cacheRead":0.08,"cacheWrite":0},"contextWindow":131072,"maxTokens":131072},"qwen/qwen3-32b":{"id":"qwen/qwen3-32b","name":"Qwen: Qwen3 32B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.08,"output":0.28,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"qwen/qwen3-8b":{"id":"qwen/qwen3-8b","name":"Qwen: Qwen3 8B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.049999999999999996,"output":0.39999999999999997,"cacheRead":0.049999999999999996,"cacheWrite":0},"contextWindow":131072,"maxTokens":8192},"qwen/qwen3-coder":{"id":"qwen/qwen3-coder","name":"Qwen: Qwen3 Coder 480B A35B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.22,"output":1.7999999999999998,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":65536},"qwen/qwen3-coder-30b-a3b-instruct":{"id":"qwen/qwen3-coder-30b-a3b-instruct","name":"Qwen: Qwen3 Coder 30B A3B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.07,"output":0.27,"cacheRead":0,"cacheWrite":0},"contextWindow":160000,"maxTokens":32768},"qwen/qwen3-coder-flash":{"id":"qwen/qwen3-coder-flash","name":"Qwen: Qwen3 Coder Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.195,"output":0.975,"cacheRead":0.039,"cacheWrite":0.24375},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3-coder-next":{"id":"qwen/qwen3-coder-next","name":"Qwen: Qwen3 Coder Next","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.11,"output":0.7999999999999999,"cacheRead":0.07,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"qwen/qwen3-coder-plus":{"id":"qwen/qwen3-coder-plus","name":"Qwen: Qwen3 Coder Plus","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.65,"output":3.25,"cacheRead":0.13,"cacheWrite":0.8125},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3-coder:free":{"id":"qwen/qwen3-coder:free","name":"Qwen: Qwen3 Coder 480B A35B (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":1048576,"maxTokens":262000},"qwen/qwen3-max":{"id":"qwen/qwen3-max","name":"Qwen: Qwen3 Max","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.78,"output":3.9,"cacheRead":0.156,"cacheWrite":0.975},"contextWindow":262144,"maxTokens":32768},"qwen/qwen3-max-thinking":{"id":"qwen/qwen3-max-thinking","name":"Qwen: Qwen3 Max Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.78,"output":3.9,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"qwen/qwen3-next-80b-a3b-instruct":{"id":"qwen/qwen3-next-80b-a3b-instruct","name":"Qwen: Qwen3 Next 80B A3B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.09,"output":1.1,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"qwen/qwen3-next-80b-a3b-instruct:free":{"id":"qwen/qwen3-next-80b-a3b-instruct:free","name":"Qwen: Qwen3 Next 80B A3B Instruct (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"qwen/qwen3-next-80b-a3b-thinking":{"id":"qwen/qwen3-next-80b-a3b-thinking","name":"Qwen: Qwen3 Next 80B A3B Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.0975,"output":0.78,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"qwen/qwen3-vl-235b-a22b-instruct":{"id":"qwen/qwen3-vl-235b-a22b-instruct","name":"Qwen: Qwen3 VL 235B A22B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.19999999999999998,"output":0.88,"cacheRead":0.11,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"qwen/qwen3-vl-235b-a22b-thinking":{"id":"qwen/qwen3-vl-235b-a22b-thinking","name":"Qwen: Qwen3 VL 235B A22B Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.26,"output":2.6,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":32768},"qwen/qwen3-vl-30b-a3b-instruct":{"id":"qwen/qwen3-vl-30b-a3b-instruct","name":"Qwen: Qwen3 VL 30B A3B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.13,"output":0.52,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"qwen/qwen3-vl-30b-a3b-thinking":{"id":"qwen/qwen3-vl-30b-a3b-thinking","name":"Qwen: Qwen3 VL 30B A3B Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.13,"output":1.56,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":32768},"qwen/qwen3-vl-32b-instruct":{"id":"qwen/qwen3-vl-32b-instruct","name":"Qwen: Qwen3 VL 32B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.10400000000000001,"output":0.41600000000000004,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":32768},"qwen/qwen3-vl-8b-instruct":{"id":"qwen/qwen3-vl-8b-instruct","name":"Qwen: Qwen3 VL 8B Instruct","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.08,"output":0.5,"cacheRead":0,"cacheWrite":0},"contextWindow":256000,"maxTokens":32768},"qwen/qwen3-vl-8b-thinking":{"id":"qwen/qwen3-vl-8b-thinking","name":"Qwen: Qwen3 VL 8B Thinking","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.117,"output":1.365,"cacheRead":0,"cacheWrite":0},"contextWindow":256000,"maxTokens":32768},"qwen/qwen3.5-122b-a10b":{"id":"qwen/qwen3.5-122b-a10b","name":"Qwen: Qwen3.5-122B-A10B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.26,"output":2.08,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"qwen/qwen3.5-27b":{"id":"qwen/qwen3.5-27b","name":"Qwen: Qwen3.5-27B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.195,"output":1.56,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536},"qwen/qwen3.5-35b-a3b":{"id":"qwen/qwen3.5-35b-a3b","name":"Qwen: Qwen3.5-35B-A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.13899999999999998,"output":1,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"qwen/qwen3.5-397b-a17b":{"id":"qwen/qwen3.5-397b-a17b","name":"Qwen: Qwen3.5 397B A17B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.39,"output":2.34,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536},"qwen/qwen3.5-9b":{"id":"qwen/qwen3.5-9b","name":"Qwen: Qwen3.5-9B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.04,"output":0.15,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":81920},"qwen/qwen3.5-flash-02-23":{"id":"qwen/qwen3.5-flash-02-23","name":"Qwen: Qwen3.5-Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.065,"output":0.26,"cacheRead":0,"cacheWrite":0.08125},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3.5-plus-02-15":{"id":"qwen/qwen3.5-plus-02-15","name":"Qwen: Qwen3.5 Plus 2026-02-15","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.26,"output":1.56,"cacheRead":0,"cacheWrite":0.325},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3.5-plus-20260420":{"id":"qwen/qwen3.5-plus-20260420","name":"Qwen: Qwen3.5 Plus 2026-04-20","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.3,"output":1.7999999999999998,"cacheRead":0,"cacheWrite":0},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3.6-27b":{"id":"qwen/qwen3.6-27b","name":"Qwen: Qwen3.6 27B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.3,"output":3.1999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"qwen/qwen3.6-35b-a3b":{"id":"qwen/qwen3.6-35b-a3b","name":"Qwen: Qwen3.6 35B A3B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.15,"output":1,"cacheRead":0,"cacheWrite":0},"contextWindow":262144,"maxTokens":262140},"qwen/qwen3.6-flash":{"id":"qwen/qwen3.6-flash","name":"Qwen: Qwen3.6 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.1875,"output":1.125,"cacheRead":0,"cacheWrite":0.234375},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3.6-max-preview":{"id":"qwen/qwen3.6-max-preview","name":"Qwen: Qwen3.6 Max Preview","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":1.04,"output":6.24,"cacheRead":0,"cacheWrite":1.3},"contextWindow":262144,"maxTokens":65536},"qwen/qwen3.6-plus":{"id":"qwen/qwen3.6-plus","name":"Qwen: Qwen3.6 Plus","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.325,"output":1.95,"cacheRead":0,"cacheWrite":0.40625},"contextWindow":1000000,"maxTokens":65536},"qwen/qwen3.7-max":{"id":"qwen/qwen3.7-max","name":"Qwen: Qwen3.7 Max","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":2.5,"output":7.5,"cacheRead":0,"cacheWrite":3.125},"contextWindow":1000000,"maxTokens":65536},"rekaai/reka-edge":{"id":"rekaai/reka-edge","name":"Reka Edge","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text","image"],"cost":{"input":0.09999999999999999,"output":0.09999999999999999,"cacheRead":0,"cacheWrite":0},"contextWindow":16384,"maxTokens":16384},"relace/relace-search":{"id":"relace/relace-search","name":"Relace: Relace Search","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":1,"output":3,"cacheRead":0,"cacheWrite":0},"contextWindow":256000,"maxTokens":128000},"sao10k/l3-euryale-70b":{"id":"sao10k/l3-euryale-70b","name":"Sao10k: Llama 3 Euryale 70B v2.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":1.48,"output":1.48,"cacheRead":0,"cacheWrite":0},"contextWindow":8192,"maxTokens":8192},"sao10k/l3.1-euryale-70b":{"id":"sao10k/l3.1-euryale-70b","name":"Sao10K: Llama 3.1 Euryale 70B v2.2","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.85,"output":0.85,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":16384},"stepfun/step-3.5-flash":{"id":"stepfun/step-3.5-flash","name":"StepFun: Step 3.5 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09,"output":0.3,"cacheRead":0.02,"cacheWrite":0},"contextWindow":262144,"maxTokens":16384},"tencent/hy3-preview":{"id":"tencent/hy3-preview","name":"Tencent: Hy3 preview","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.06599999999999999,"output":0.26,"cacheRead":0.029,"cacheWrite":0},"contextWindow":262144,"maxTokens":262144},"thedrummer/rocinante-12b":{"id":"thedrummer/rocinante-12b","name":"TheDrummer: Rocinante 12B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.16999999999999998,"output":0.43,"cacheRead":0,"cacheWrite":0},"contextWindow":32768,"maxTokens":32768},"thedrummer/unslopnemo-12b":{"id":"thedrummer/unslopnemo-12b","name":"TheDrummer: UnslopNemo 12B","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.39999999999999997,"output":0.39999999999999997,"cacheRead":0,"cacheWrite":0},"contextWindow":32768,"maxTokens":32768},"upstage/solar-pro-3":{"id":"upstage/solar-pro-3","name":"Upstage: Solar Pro 3","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.15,"output":0.6,"cacheRead":0.015,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"x-ai/grok-4.20":{"id":"x-ai/grok-4.20","name":"xAI: Grok 4.20","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":2.5,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":2000000,"maxTokens":16384},"x-ai/grok-4.3":{"id":"x-ai/grok-4.3","name":"xAI: Grok 4.3","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.25,"output":2.5,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":1000000,"maxTokens":16384},"x-ai/grok-build-0.1":{"id":"x-ai/grok-build-0.1","name":"xAI: Grok Build 0.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":2,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":256000,"maxTokens":16384},"xiaomi/mimo-v2-flash":{"id":"xiaomi/mimo-v2-flash","name":"Xiaomi: MiMo-V2-Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.3,"cacheRead":0.01,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536},"xiaomi/mimo-v2-omni":{"id":"xiaomi/mimo-v2-omni","name":"Xiaomi: MiMo-V2-Omni","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.39999999999999997,"output":2,"cacheRead":0.08,"cacheWrite":0},"contextWindow":262144,"maxTokens":65536},"xiaomi/mimo-v2-pro":{"id":"xiaomi/mimo-v2-pro","name":"Xiaomi: MiMo-V2-Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":1,"output":3,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072},"xiaomi/mimo-v2.5":{"id":"xiaomi/mimo-v2.5","name":"Xiaomi: MiMo-V2.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.39999999999999997,"output":2,"cacheRead":0.08,"cacheWrite":0},"contextWindow":1048576,"maxTokens":131072},"xiaomi/mimo-v2.5-pro":{"id":"xiaomi/mimo-v2.5-pro","name":"Xiaomi: MiMo-V2.5-Pro","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":1,"output":3,"cacheRead":0.19999999999999998,"cacheWrite":0},"contextWindow":1048576,"maxTokens":16384},"z-ai/glm-4-32b":{"id":"z-ai/glm-4-32b","name":"Z.ai: GLM 4 32B ","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":false,"input":["text"],"cost":{"input":0.09999999999999999,"output":0.09999999999999999,"cacheRead":0,"cacheWrite":0},"contextWindow":128000,"maxTokens":16384},"z-ai/glm-4.5":{"id":"z-ai/glm-4.5","name":"Z.ai: GLM 4.5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.6,"output":2.2,"cacheRead":0.11,"cacheWrite":0},"contextWindow":131072,"maxTokens":98304},"z-ai/glm-4.5-air":{"id":"z-ai/glm-4.5-air","name":"Z.ai: GLM 4.5 Air","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.13,"output":0.85,"cacheRead":0.024999999999999998,"cacheWrite":0},"contextWindow":131072,"maxTokens":98304},"z-ai/glm-4.5-air:free":{"id":"z-ai/glm-4.5-air:free","name":"Z.ai: GLM 4.5 Air (free)","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0},"contextWindow":131072,"maxTokens":96000},"z-ai/glm-4.5v":{"id":"z-ai/glm-4.5v","name":"Z.ai: GLM 4.5V","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.6,"output":1.7999999999999998,"cacheRead":0.11,"cacheWrite":0},"contextWindow":65536,"maxTokens":16384},"z-ai/glm-4.6":{"id":"z-ai/glm-4.6","name":"Z.ai: GLM 4.6","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.43,"output":1.74,"cacheRead":0.08,"cacheWrite":0},"contextWindow":202752,"maxTokens":131072},"z-ai/glm-4.6v":{"id":"z-ai/glm-4.6v","name":"Z.ai: GLM 4.6V","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.3,"output":0.8999999999999999,"cacheRead":0.049999999999999996,"cacheWrite":0},"contextWindow":131072,"maxTokens":24000},"z-ai/glm-4.7":{"id":"z-ai/glm-4.7","name":"Z.ai: GLM 4.7","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.39999999999999997,"output":1.75,"cacheRead":0.08,"cacheWrite":0},"contextWindow":202752,"maxTokens":131072},"z-ai/glm-4.7-flash":{"id":"z-ai/glm-4.7-flash","name":"Z.ai: GLM 4.7 Flash","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.06,"output":0.39999999999999997,"cacheRead":0.01,"cacheWrite":0},"contextWindow":202752,"maxTokens":16384},"z-ai/glm-5":{"id":"z-ai/glm-5","name":"Z.ai: GLM 5","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.6,"output":1.92,"cacheRead":0.12,"cacheWrite":0},"contextWindow":202752,"maxTokens":16384},"z-ai/glm-5-turbo":{"id":"z-ai/glm-5-turbo","name":"Z.ai: GLM 5 Turbo","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":1.2,"output":4,"cacheRead":0.24,"cacheWrite":0},"contextWindow":202752,"maxTokens":131072},"z-ai/glm-5.1":{"id":"z-ai/glm-5.1","name":"Z.ai: GLM 5.1","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text"],"cost":{"input":0.98,"output":3.08,"cacheRead":0.182,"cacheWrite":0},"contextWindow":202752,"maxTokens":16384},"z-ai/glm-5v-turbo":{"id":"z-ai/glm-5v-turbo","name":"Z.ai: GLM 5V Turbo","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.2,"output":4,"cacheRead":0.24,"cacheWrite":0},"contextWindow":202752,"maxTokens":131072},"~anthropic/claude-haiku-latest":{"id":"~anthropic/claude-haiku-latest","name":"Anthropic Claude Haiku Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1,"output":5,"cacheRead":0.09999999999999999,"cacheWrite":1.25},"contextWindow":200000,"maxTokens":64000},"~anthropic/claude-opus-latest":{"id":"~anthropic/claude-opus-latest","name":"Anthropic: Claude Opus Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":25,"cacheRead":0.5,"cacheWrite":6.25},"contextWindow":1000000,"maxTokens":128000},"~anthropic/claude-sonnet-latest":{"id":"~anthropic/claude-sonnet-latest","name":"Anthropic Claude Sonnet Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":3,"output":15,"cacheRead":0.3,"cacheWrite":3.75},"contextWindow":1000000,"maxTokens":128000},"~google/gemini-flash-latest":{"id":"~google/gemini-flash-latest","name":"Google Gemini Flash Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":1.5,"output":9,"cacheRead":0.15,"cacheWrite":0.08333333333333334},"contextWindow":1048576,"maxTokens":65536},"~google/gemini-pro-latest":{"id":"~google/gemini-pro-latest","name":"Google Gemini Pro Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":2,"output":12,"cacheRead":0.19999999999999998,"cacheWrite":0.375},"contextWindow":1048576,"maxTokens":65536},"~moonshotai/kimi-latest":{"id":"~moonshotai/kimi-latest","name":"MoonshotAI Kimi Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.73,"output":3.49,"cacheRead":0.25,"cacheWrite":0},"contextWindow":262144,"maxTokens":262142},"~openai/gpt-latest":{"id":"~openai/gpt-latest","name":"OpenAI GPT Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":5,"output":30,"cacheRead":0.5,"cacheWrite":0},"contextWindow":1050000,"maxTokens":128000},"~openai/gpt-mini-latest":{"id":"~openai/gpt-mini-latest","name":"OpenAI GPT Mini Latest","api":"openai-completions","provider":"openrouter","baseUrl":"https://openrouter.ai/api/v1","reasoning":true,"input":["text","image"],"cost":{"input":0.75,"output":4.5,"cacheRead":0.075,"cacheWrite":0},"contextWindow":400000,"maxTokens":128000}}}`
-
-var modelProviderOrder = []Provider{
- "openai",
- "openrouter",
-}
-
-var modelIDOrderJSON = `{"openai":["gpt-4","gpt-4-turbo","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-4o","gpt-4o-2024-05-13","gpt-4o-2024-08-06","gpt-4o-2024-11-20","gpt-4o-mini","gpt-5","gpt-5-codex","gpt-5-mini","gpt-5-nano","gpt-5-pro","gpt-5.1","gpt-5.1-chat-latest","gpt-5.1-codex","gpt-5.1-codex-max","gpt-5.1-codex-mini","gpt-5.2","gpt-5.2-chat-latest","gpt-5.2-codex","gpt-5.2-pro","gpt-5.3-chat-latest","gpt-5.3-codex","gpt-5.3-codex-spark","gpt-5.4","gpt-5.4-mini","gpt-5.4-nano","gpt-5.4-pro","gpt-5.5","gpt-5.5-pro","o1","o1-pro","o3","o3-deep-research","o3-mini","o3-pro","o4-mini","o4-mini-deep-research"],"openrouter":["ai21/jamba-large-1.7","alibaba/tongyi-deepresearch-30b-a3b","amazon/nova-2-lite-v1","amazon/nova-lite-v1","amazon/nova-micro-v1","amazon/nova-premier-v1","amazon/nova-pro-v1","anthropic/claude-3-haiku","anthropic/claude-3.5-haiku","anthropic/claude-haiku-4.5","anthropic/claude-opus-4","anthropic/claude-opus-4.1","anthropic/claude-opus-4.5","anthropic/claude-opus-4.6","anthropic/claude-opus-4.6-fast","anthropic/claude-opus-4.7","anthropic/claude-opus-4.7-fast","anthropic/claude-sonnet-4","anthropic/claude-sonnet-4.5","anthropic/claude-sonnet-4.6","arcee-ai/trinity-large-thinking","arcee-ai/trinity-large-thinking:free","arcee-ai/trinity-mini","arcee-ai/virtuoso-large","baidu/cobuddy:free","baidu/ernie-4.5-21b-a3b","baidu/ernie-4.5-vl-28b-a3b","bytedance-seed/seed-1.6","bytedance-seed/seed-1.6-flash","bytedance-seed/seed-2.0-lite","bytedance-seed/seed-2.0-mini","cohere/command-r-08-2024","cohere/command-r-plus-08-2024","deepseek/deepseek-chat","deepseek/deepseek-chat-v3-0324","deepseek/deepseek-chat-v3.1","deepseek/deepseek-r1","deepseek/deepseek-r1-0528","deepseek/deepseek-v3.1-terminus","deepseek/deepseek-v3.2","deepseek/deepseek-v3.2-exp","deepseek/deepseek-v4-flash","deepseek/deepseek-v4-flash:free","deepseek/deepseek-v4-pro","essentialai/rnj-1-instruct","google/gemini-2.0-flash-001","google/gemini-2.0-flash-lite-001","google/gemini-2.5-flash","google/gemini-2.5-flash-lite","google/gemini-2.5-flash-lite-preview-09-2025","google/gemini-2.5-pro","google/gemini-2.5-pro-preview","google/gemini-2.5-pro-preview-05-06","google/gemini-3-flash-preview","google/gemini-3.1-flash-lite","google/gemini-3.1-flash-lite-preview","google/gemini-3.1-pro-preview","google/gemini-3.1-pro-preview-customtools","google/gemini-3.5-flash","google/gemma-3-12b-it","google/gemma-3-27b-it","google/gemma-4-26b-a4b-it","google/gemma-4-26b-a4b-it:free","google/gemma-4-31b-it","google/gemma-4-31b-it:free","ibm-granite/granite-4.1-8b","inception/mercury-2","inclusionai/ling-2.6-1t","inclusionai/ling-2.6-flash","inclusionai/ring-2.6-1t","kwaipilot/kat-coder-pro-v2","meta-llama/llama-3.1-70b-instruct","meta-llama/llama-3.1-8b-instruct","meta-llama/llama-3.3-70b-instruct","meta-llama/llama-3.3-70b-instruct:free","meta-llama/llama-4-scout","minimax/minimax-m1","minimax/minimax-m2","minimax/minimax-m2.1","minimax/minimax-m2.5","minimax/minimax-m2.5:free","minimax/minimax-m2.7","mistralai/codestral-2508","mistralai/devstral-2512","mistralai/devstral-medium","mistralai/devstral-small","mistralai/ministral-14b-2512","mistralai/ministral-3b-2512","mistralai/ministral-8b-2512","mistralai/mistral-large","mistralai/mistral-large-2407","mistralai/mistral-large-2411","mistralai/mistral-large-2512","mistralai/mistral-medium-3","mistralai/mistral-medium-3-5","mistralai/mistral-medium-3.1","mistralai/mistral-nemo","mistralai/mistral-saba","mistralai/mistral-small-2603","mistralai/mistral-small-3.2-24b-instruct","mistralai/mixtral-8x22b-instruct","mistralai/pixtral-large-2411","mistralai/voxtral-small-24b-2507","moonshotai/kimi-k2","moonshotai/kimi-k2-0905","moonshotai/kimi-k2-thinking","moonshotai/kimi-k2.5","moonshotai/kimi-k2.6","nex-agi/deepseek-v3.1-nex-n1","nvidia/llama-3.3-nemotron-super-49b-v1.5","nvidia/nemotron-3-nano-30b-a3b","nvidia/nemotron-3-nano-30b-a3b:free","nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free","nvidia/nemotron-3-super-120b-a12b","nvidia/nemotron-3-super-120b-a12b:free","nvidia/nemotron-nano-12b-v2-vl:free","nvidia/nemotron-nano-9b-v2","nvidia/nemotron-nano-9b-v2:free","openai/gpt-3.5-turbo","openai/gpt-3.5-turbo-0613","openai/gpt-3.5-turbo-16k","openai/gpt-4","openai/gpt-4-0314","openai/gpt-4-1106-preview","openai/gpt-4-turbo","openai/gpt-4-turbo-preview","openai/gpt-4.1","openai/gpt-4.1-mini","openai/gpt-4.1-nano","openai/gpt-4o","openai/gpt-4o-2024-05-13","openai/gpt-4o-2024-08-06","openai/gpt-4o-2024-11-20","openai/gpt-4o-audio-preview","openai/gpt-4o-mini","openai/gpt-4o-mini-2024-07-18","openai/gpt-5","openai/gpt-5-codex","openai/gpt-5-mini","openai/gpt-5-nano","openai/gpt-5-pro","openai/gpt-5.1","openai/gpt-5.1-chat","openai/gpt-5.1-codex","openai/gpt-5.1-codex-max","openai/gpt-5.1-codex-mini","openai/gpt-5.2","openai/gpt-5.2-chat","openai/gpt-5.2-codex","openai/gpt-5.2-pro","openai/gpt-5.3-chat","openai/gpt-5.3-codex","openai/gpt-5.4","openai/gpt-5.4-mini","openai/gpt-5.4-nano","openai/gpt-5.4-pro","openai/gpt-5.5","openai/gpt-5.5-pro","openai/gpt-audio","openai/gpt-audio-mini","openai/gpt-chat-latest","openai/gpt-oss-120b","openai/gpt-oss-120b:free","openai/gpt-oss-20b","openai/gpt-oss-20b:free","openai/gpt-oss-safeguard-20b","openai/o1","openai/o3","openai/o3-deep-research","openai/o3-mini","openai/o3-mini-high","openai/o3-pro","openai/o4-mini","openai/o4-mini-deep-research","openai/o4-mini-high","openrouter/auto","openrouter/free","openrouter/owl-alpha","poolside/laguna-m.1:free","poolside/laguna-xs.2:free","prime-intellect/intellect-3","qwen/qwen-2.5-72b-instruct","qwen/qwen-2.5-7b-instruct","qwen/qwen-plus","qwen/qwen-plus-2025-07-28","qwen/qwen-plus-2025-07-28:thinking","qwen/qwen3-14b","qwen/qwen3-235b-a22b","qwen/qwen3-235b-a22b-2507","qwen/qwen3-235b-a22b-thinking-2507","qwen/qwen3-30b-a3b","qwen/qwen3-30b-a3b-instruct-2507","qwen/qwen3-30b-a3b-thinking-2507","qwen/qwen3-32b","qwen/qwen3-8b","qwen/qwen3-coder","qwen/qwen3-coder-30b-a3b-instruct","qwen/qwen3-coder-flash","qwen/qwen3-coder-next","qwen/qwen3-coder-plus","qwen/qwen3-coder:free","qwen/qwen3-max","qwen/qwen3-max-thinking","qwen/qwen3-next-80b-a3b-instruct","qwen/qwen3-next-80b-a3b-instruct:free","qwen/qwen3-next-80b-a3b-thinking","qwen/qwen3-vl-235b-a22b-instruct","qwen/qwen3-vl-235b-a22b-thinking","qwen/qwen3-vl-30b-a3b-instruct","qwen/qwen3-vl-30b-a3b-thinking","qwen/qwen3-vl-32b-instruct","qwen/qwen3-vl-8b-instruct","qwen/qwen3-vl-8b-thinking","qwen/qwen3.5-122b-a10b","qwen/qwen3.5-27b","qwen/qwen3.5-35b-a3b","qwen/qwen3.5-397b-a17b","qwen/qwen3.5-9b","qwen/qwen3.5-flash-02-23","qwen/qwen3.5-plus-02-15","qwen/qwen3.5-plus-20260420","qwen/qwen3.6-27b","qwen/qwen3.6-35b-a3b","qwen/qwen3.6-flash","qwen/qwen3.6-max-preview","qwen/qwen3.6-plus","qwen/qwen3.7-max","rekaai/reka-edge","relace/relace-search","sao10k/l3-euryale-70b","sao10k/l3.1-euryale-70b","stepfun/step-3.5-flash","tencent/hy3-preview","thedrummer/rocinante-12b","thedrummer/unslopnemo-12b","upstage/solar-pro-3","x-ai/grok-4.20","x-ai/grok-4.3","x-ai/grok-build-0.1","xiaomi/mimo-v2-flash","xiaomi/mimo-v2-omni","xiaomi/mimo-v2-pro","xiaomi/mimo-v2.5","xiaomi/mimo-v2.5-pro","z-ai/glm-4-32b","z-ai/glm-4.5","z-ai/glm-4.5-air","z-ai/glm-4.5-air:free","z-ai/glm-4.5v","z-ai/glm-4.6","z-ai/glm-4.6v","z-ai/glm-4.7","z-ai/glm-4.7-flash","z-ai/glm-5","z-ai/glm-5-turbo","z-ai/glm-5.1","z-ai/glm-5v-turbo","~anthropic/claude-haiku-latest","~anthropic/claude-opus-latest","~anthropic/claude-sonnet-latest","~google/gemini-flash-latest","~google/gemini-pro-latest","~moonshotai/kimi-latest","~openai/gpt-latest","~openai/gpt-mini-latest"]}`
-
-var Models = mustLoadModels()
-var modelIDOrder = mustLoadModelIDOrder()
-
-func mustLoadModels() map[Provider]map[string]Model {
- var raw map[Provider]map[string]Model
- if err := json.Unmarshal([]byte(modelsJSON), &raw); err != nil {
- panic(err)
- }
- return raw
-}
-
-func mustLoadModelIDOrder() map[Provider][]string {
- var raw map[Provider][]string
- if err := json.Unmarshal([]byte(modelIDOrderJSON), &raw); err != nil {
- panic(err)
- }
- return raw
-}
diff --git a/pkg/ai/models_test.go b/pkg/ai/models_test.go
index 3d74ee08..ab7dc76c 100644
--- a/pkg/ai/models_test.go
+++ b/pkg/ai/models_test.go
@@ -2,24 +2,6 @@ package ai
import "testing"
-func TestModelRegistryGeneratedSurface(t *testing.T) {
- providers := GetProviders()
- if len(providers) != 2 || providers[0] != ProviderOpenAI || providers[1] != ProviderOpenRouter {
- t.Fatalf("expected generated provider order, got %#v", providers)
- }
- models := GetModels(ProviderOpenAI)
- if len(models) == 0 {
- t.Fatalf("expected generated OpenAI models")
- }
- model, ok := GetModel(ProviderOpenAI, models[0].ID)
- if !ok || model.ID != models[0].ID || model.Provider != ProviderOpenAI {
- t.Fatalf("expected model lookup to round trip, got %#v ok=%v", model, ok)
- }
- if _, ok := GetModel("missing", "missing"); ok {
- t.Fatalf("expected missing model lookup to fail")
- }
-}
-
func TestGetSupportedThinkingLevels(t *testing.T) {
if got := GetSupportedThinkingLevels(Model{}); len(got) != 1 || got[0] != ModelThinkingLevelOff {
t.Fatalf("expected non-reasoning model to support off only, got %#v", got)
diff --git a/pkg/ai/providers/anthropic.go b/pkg/ai/providers/anthropic.go
index 4362a282..dea1eed5 100644
--- a/pkg/ai/providers/anthropic.go
+++ b/pkg/ai/providers/anthropic.go
@@ -19,6 +19,8 @@ import (
const (
fineGrainedToolStreamingBeta = "fine-grained-tool-streaming-2025-05-14"
interleavedThinkingBeta = "interleaved-thinking-2025-05-14"
+ webFetchBeta = "web-fetch-2025-09-10"
+ webFetchBetaMetadataKey = "anthropic_web_fetch_beta"
claudeCodeVersion = "2.1.75"
)
@@ -112,6 +114,8 @@ func StreamAnthropic(ctx context.Context, model ai.Model, llmContext ai.Context,
}
stream.Push(ai.AssistantMessageEvent{Type: "start", Partial: &output})
state := newAnthropicStreamState()
+ sawMessageStart := false
+ sawMessageStop := false
err = iterateSSE(response.Body, func(sse serverSentEvent) error {
if sse.Event == "error" {
return errors.New(sse.Data)
@@ -127,6 +131,17 @@ func StreamAnthropic(ctx context.Context, model ai.Model, llmContext ai.Context,
return fmt.Errorf("could not parse Anthropic SSE event %s: %w; data=%s; raw=%s", sse.Event, err, sse.Data, strings.Join(sse.Raw, "\n"))
}
}
+ switch event["type"] {
+ case "message_start":
+ sawMessageStart = true
+ case "message_stop":
+ sawMessageStop = true
+ }
+ citations := providerCitationsFromAny(event, model.Provider, max(0, len(contentBlocks(output.Content))-1))
+ if len(citations) > 0 {
+ output.Citations = append(output.Citations, citations...)
+ stream.Push(ai.AssistantMessageEvent{Type: "source", Partial: &output})
+ }
state.apply(stream, &output, model, llmContext, isOAuth, event)
return nil
})
@@ -134,6 +149,10 @@ func StreamAnthropic(ctx context.Context, model ai.Model, llmContext ai.Context,
pushFinalError(stream, &output, err.Error())
return
}
+ if sawMessageStart && !sawMessageStop {
+ pushFinalError(stream, &output, "Anthropic stream ended before message_stop")
+ return
+ }
stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: output.StopReason, Message: &output})
}()
return stream
@@ -269,7 +288,11 @@ func ConvertAnthropicMessages(model ai.Model, llmContext ai.Context, isOAuth boo
if isOAuth {
name = toClaudeCodeName(name)
}
- blocks = append(blocks, map[string]any{"type": "tool_use", "id": block.ID, "name": name, "input": block.Arguments})
+ input := block.Arguments
+ if input == nil {
+ input = map[string]any{}
+ }
+ blocks = append(blocks, map[string]any{"type": "tool_use", "id": block.ID, "name": name, "input": input})
}
}
if len(blocks) > 0 {
@@ -361,11 +384,42 @@ func doAnthropicRequest(ctx context.Context, model ai.Model, llmContext ai.Conte
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
- return nil, fmt.Errorf("Anthropic API error (%d): %s", resp.StatusCode, strings.TrimSpace(string(payload)))
+ return nil, errors.New(formatAnthropicAPIError(resp.StatusCode, payload))
}
return resp, nil
}
+func formatAnthropicAPIError(statusCode int, payload []byte) string {
+ detail := strings.TrimSpace(string(payload))
+ if parsed := providerErrorMessage(payload); parsed != "" {
+ detail = parsed
+ }
+ if detail == "" {
+ statusText := strings.TrimSpace(fmt.Sprintf("%d %s", statusCode, http.StatusText(statusCode)))
+ if statusText == "" {
+ statusText = fmt.Sprintf("upstream status %d", statusCode)
+ }
+ detail = statusText + " (no body)"
+ }
+ return fmt.Sprintf("Anthropic API error (%d): %s", statusCode, detail)
+}
+
+func providerErrorMessage(payload []byte) string {
+ var parsed struct {
+ Message string `json:"message"`
+ Error struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if len(bytes.TrimSpace(payload)) == 0 || json.Unmarshal(payload, &parsed) != nil {
+ return ""
+ }
+ if parsed.Error.Message != "" {
+ return parsed.Error.Message
+ }
+ return parsed.Message
+}
+
func anthropicHeaders(model ai.Model, llmContext ai.Context, options AnthropicOptions, isOAuth bool) map[string]string {
headers := map[string]string{"Anthropic-Dangerous-Direct-Browser-Access": "true"}
betas := []string{}
@@ -379,6 +433,9 @@ func anthropicHeaders(model ai.Model, llmContext ai.Context, options AnthropicOp
if interleaved && !getAnthropicCompat(model).ForceAdaptiveThinking {
betas = append(betas, interleavedThinkingBeta)
}
+ if enabled, _ := options.Metadata[webFetchBetaMetadataKey].(bool); enabled {
+ betas = append(betas, webFetchBeta)
+ }
if isBeeperAIProxyBaseURL(model.BaseURL) {
headers["Authorization"] = "Bearer " + options.APIKey
if len(betas) > 0 {
@@ -394,9 +451,9 @@ func anthropicHeaders(model ai.Model, llmContext ai.Context, options AnthropicOp
if len(betas) > 0 {
headers["Anthropic-Beta"] = strings.Join(betas, ",")
}
- if options.SessionID != "" && getAnthropicCompat(model).SendSessionAffinityHeaders && resolveAnthropicCacheRetention(options.CacheRetention) != ai.CacheRetentionNone {
- headers["X-Session-Affinity"] = options.SessionID
- }
+ }
+ if options.SessionID != "" && getAnthropicCompat(model).SendSessionAffinityHeaders && resolveAnthropicCacheRetention(options.CacheRetention) != ai.CacheRetentionNone {
+ headers["X-Session-Affinity"] = options.SessionID
}
for key, value := range model.Headers {
headers[key] = value
@@ -415,7 +472,7 @@ func getAnthropicCompat(model ai.Model) resolvedAnthropicCompat {
SupportsLongCacheRetention: compatBool(model, "supportsLongCacheRetention", !isFireworks),
SendSessionAffinityHeaders: compatBool(model, "sendSessionAffinityHeaders", isFireworks || isCloudflareGatewayAnthropic),
SupportsCacheControlOnTools: compatBool(model, "supportsCacheControlOnTools", !isFireworks),
- ForceAdaptiveThinking: compatBool(model, "forceAdaptiveThinking", false),
+ ForceAdaptiveThinking: model.ReasoningMode == ai.ModelReasoningModeAdaptive || compatBool(model, "forceAdaptiveThinking", false),
AllowEmptySignature: compatBool(model, "allowEmptySignature", false),
}
}
@@ -540,12 +597,21 @@ func isAnthropicMessageEvent(event string) bool {
}
type anthropicStreamState struct {
- anthropicIndexes map[int]int
- partialJSON map[int]string
+ anthropicIndexes map[int]int
+ partialJSON map[int]string
+ nativeToolsByIndex map[int]ai.ToolCall
+ nativeToolsByID map[string]ai.ToolCall
+ nativeToolArgsByIndex map[int]string
}
func newAnthropicStreamState() *anthropicStreamState {
- return &anthropicStreamState{anthropicIndexes: map[int]int{}, partialJSON: map[int]string{}}
+ return &anthropicStreamState{
+ anthropicIndexes: map[int]int{},
+ partialJSON: map[int]string{},
+ nativeToolsByIndex: map[int]ai.ToolCall{},
+ nativeToolsByID: map[string]ai.ToolCall{},
+ nativeToolArgsByIndex: map[int]string{},
+ }
}
func (s *anthropicStreamState) apply(stream *ai.AssistantMessageEventStream, output *ai.Message, model ai.Model, llmContext ai.Context, isOAuth bool, event map[string]any) {
@@ -566,18 +632,22 @@ func (s *anthropicStreamState) apply(stream *ai.AssistantMessageEventStream, out
return
}
contentIndex := len(output.Content.([]ai.ContentBlock))
- s.anthropicIndexes[index] = contentIndex
switch blockMap["type"] {
case "text":
+ s.anthropicIndexes[index] = contentIndex
appendContentBlock(output, ai.ContentBlock{Type: "text"})
stream.Push(ai.AssistantMessageEvent{Type: "text_start", ContentIndex: contentIndex, Partial: output})
case "thinking":
+ s.anthropicIndexes[index] = contentIndex
appendContentBlock(output, ai.ContentBlock{Type: "thinking"})
stream.Push(ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: contentIndex, Partial: output})
case "redacted_thinking":
+ s.anthropicIndexes[index] = contentIndex
appendContentBlock(output, ai.ContentBlock{Type: "thinking", Thinking: "[Reasoning redacted]", ThinkingSignature: stringFromAny(blockMap["data"]), Redacted: true})
stream.Push(ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: contentIndex, Partial: output})
+ stream.Push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: contentIndex, Delta: "[Reasoning redacted]", Partial: output})
case "tool_use":
+ s.anthropicIndexes[index] = contentIndex
name := stringFromAny(blockMap["name"])
if isOAuth {
name = fromClaudeCodeName(name, llmContext.Tools)
@@ -591,8 +661,46 @@ func (s *anthropicStreamState) apply(stream *ai.AssistantMessageEventStream, out
}
appendContentBlock(output, ai.ContentBlock{Type: "toolCall", ID: stringFromAny(blockMap["id"]), Name: name, Arguments: arguments})
stream.Push(ai.AssistantMessageEvent{Type: "toolcall_start", ContentIndex: contentIndex, Partial: output})
+ if len(arguments) > 0 {
+ toolCall := ai.ToolCall{Type: "toolCall", ID: stringFromAny(blockMap["id"]), Name: name, Arguments: arguments}
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: contentIndex, Delta: s.partialJSON[contentIndex], ToolCall: &toolCall, Partial: output})
+ }
+ case "server_tool_use":
+ toolCall, ok := anthropicNativeServerToolCall(blockMap, index)
+ if !ok {
+ return
+ }
+ s.nativeToolsByIndex[index] = toolCall
+ s.nativeToolsByID[toolCall.ID] = toolCall
+ if len(toolCall.Arguments) > 0 {
+ s.nativeToolArgsByIndex[index] = mustJSON(toolCall.Arguments)
+ }
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_start", ToolCall: &toolCall, Partial: output})
+ if len(toolCall.Arguments) > 0 {
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_delta", Delta: mustJSON(toolCall.Arguments), ToolCall: &toolCall, Partial: output})
+ }
+ case "web_search_tool_result", "web_fetch_tool_result":
+ toolCall := s.anthropicNativeToolCallForResult(blockMap)
+ if toolCall.ID == "" {
+ return
+ }
+ stream.Push(ai.AssistantMessageEvent{Type: "toolresult", ToolCall: &toolCall, CustomValue: anthropicNativeToolResult(blockMap), Partial: output})
}
case "content_block_delta":
+ blockIndex := intFromAny(event["index"])
+ if toolCall, ok := s.nativeToolsByIndex[blockIndex]; ok {
+ delta, _ := event["delta"].(map[string]any)
+ if delta == nil || delta["type"] != "input_json_delta" {
+ return
+ }
+ part := stringFromAny(delta["partial_json"])
+ s.nativeToolArgsByIndex[blockIndex] += part
+ toolCall.Arguments = parseJSONMap(s.nativeToolArgsByIndex[blockIndex])
+ s.nativeToolsByIndex[blockIndex] = toolCall
+ s.nativeToolsByID[toolCall.ID] = toolCall
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_delta", Delta: part, ToolCall: &toolCall, Partial: output})
+ return
+ }
contentIndex, ok := s.anthropicIndexes[intFromAny(event["index"])]
if !ok {
return
@@ -624,7 +732,14 @@ func (s *anthropicStreamState) apply(stream *ai.AssistantMessageEventStream, out
stream.Push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: contentIndex, Delta: part, Partial: output})
}
case "content_block_stop":
- contentIndex, ok := s.anthropicIndexes[intFromAny(event["index"])]
+ blockIndex := intFromAny(event["index"])
+ if toolCall, ok := s.nativeToolsByIndex[blockIndex]; ok {
+ s.nativeToolsByID[toolCall.ID] = toolCall
+ delete(s.nativeToolsByIndex, blockIndex)
+ delete(s.nativeToolArgsByIndex, blockIndex)
+ return
+ }
+ contentIndex, ok := s.anthropicIndexes[blockIndex]
if !ok {
return
}
@@ -654,6 +769,134 @@ func (s *anthropicStreamState) apply(stream *ai.AssistantMessageEventStream, out
}
}
+func anthropicNativeServerToolCall(block map[string]any, fallbackIndex int) (ai.ToolCall, bool) {
+ name := anthropicNativeToolName(stringFromAny(block["name"]))
+ if name == "" {
+ return ai.ToolCall{}, false
+ }
+ id := stringFromAny(block["id"])
+ if id == "" {
+ id = fmt.Sprintf("server_tool_%d", fallbackIndex+1)
+ }
+ arguments := map[string]any{}
+ if input, ok := block["input"]; ok {
+ arguments = parseJSONMap(mustJSON(input))
+ }
+ return ai.ToolCall{Type: "toolCall", ID: id, Name: name, Arguments: arguments}, true
+}
+
+func (s *anthropicStreamState) anthropicNativeToolCallForResult(block map[string]any) ai.ToolCall {
+ id := stringFromAny(block["tool_use_id"])
+ if id != "" {
+ if toolCall, ok := s.nativeToolsByID[id]; ok {
+ return toolCall
+ }
+ }
+ name := anthropicNativeResultToolName(stringFromAny(block["type"]))
+ if name == "" || id == "" {
+ return ai.ToolCall{}
+ }
+ return ai.ToolCall{Type: "toolCall", ID: id, Name: name, Arguments: map[string]any{}}
+}
+
+func anthropicNativeToolName(name string) string {
+ switch name {
+ case "web_search":
+ return "web_search"
+ case "web_fetch":
+ return "fetch"
+ default:
+ return ""
+ }
+}
+
+func anthropicNativeResultToolName(blockType string) string {
+ switch blockType {
+ case "web_search_tool_result":
+ return "web_search"
+ case "web_fetch_tool_result":
+ return "fetch"
+ default:
+ return ""
+ }
+}
+
+func anthropicNativeToolResult(block map[string]any) map[string]any {
+ result := map[string]any{
+ "state": "complete",
+ "status": "success",
+ "provider": "anthropic",
+ "native": true,
+ }
+ content := block["content"]
+ if errorCode := anthropicNativeToolErrorCode(content); errorCode != "" {
+ result["state"] = "error"
+ result["status"] = "failed"
+ result["reason"] = errorCode
+ return result
+ }
+ switch block["type"] {
+ case "web_search_tool_result":
+ results := anthropicNativeSearchResults(content)
+ result["results"] = results
+ result["count"] = len(results)
+ case "web_fetch_tool_result":
+ if data, _ := content.(map[string]any); data != nil {
+ if url := stringFromAny(data["url"]); url != "" {
+ result["url"] = url
+ result["final_url"] = url
+ }
+ if nested, _ := data["content"].(map[string]any); nested != nil {
+ if title := firstCitationString(stringFromAny(nested["title"]), documentTitleFromProviderContent(nested)); title != "" {
+ result["title"] = title
+ }
+ }
+ if retrievedAt := stringFromAny(data["retrieved_at"]); retrievedAt != "" {
+ result["retrieved_at"] = retrievedAt
+ }
+ }
+ }
+ return result
+}
+
+func anthropicNativeToolErrorCode(content any) string {
+ data, _ := content.(map[string]any)
+ if data == nil {
+ return ""
+ }
+ if strings.Contains(stringFromAny(data["type"]), "error") {
+ if code := stringFromAny(data["error_code"]); code != "" {
+ return code
+ }
+ return stringFromAny(data["type"])
+ }
+ return ""
+}
+
+func anthropicNativeSearchResults(content any) []map[string]any {
+ items, _ := content.([]any)
+ results := make([]map[string]any, 0, len(items))
+ for _, item := range items {
+ data, _ := item.(map[string]any)
+ if data == nil || data["type"] != "web_search_result" {
+ continue
+ }
+ result := map[string]any{}
+ for _, key := range []string{"url", "title", "page_age"} {
+ if value := stringFromAny(data[key]); value != "" {
+ result[key] = value
+ }
+ }
+ if pageAge := stringFromAny(data["page_age"]); pageAge != "" {
+ result["published_at"] = pageAge
+ }
+ if len(result) > 0 {
+ results = append(results, result)
+ }
+ }
+ return results
+}
+
func appendContentBlock(output *ai.Message, block ai.ContentBlock) {
blocks, _ := output.Content.([]ai.ContentBlock)
blocks = append(blocks, block)
diff --git a/pkg/ai/providers/anthropic_test.go b/pkg/ai/providers/anthropic_test.go
index 7de8d5c1..94e2d8a7 100644
--- a/pkg/ai/providers/anthropic_test.go
+++ b/pkg/ai/providers/anthropic_test.go
@@ -1,11 +1,44 @@
package providers
import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
ai "github.com/beeper/ai-bridge/pkg/ai"
)
+func TestFormatAnthropicAPIErrorParsesProviderBody(t *testing.T) {
+ got := formatAnthropicAPIError(http.StatusBadRequest, []byte(`{"type":"error","error":{"type":"invalid_request_error","message":"messages.0.content: Field required"}}`))
+ want := "Anthropic API error (400): messages.0.content: Field required"
+ if got != want {
+ t.Fatalf("formatAnthropicAPIError() = %q, want %q", got, want)
+ }
+}
+
+func TestFormatAnthropicAPIErrorPreservesEmptyBodyStatus(t *testing.T) {
+ got := formatAnthropicAPIError(http.StatusBadRequest, nil)
+ want := "Anthropic API error (400): 400 Bad Request (no body)"
+ if got != want {
+ t.Fatalf("formatAnthropicAPIError() = %q, want %q", got, want)
+ }
+}
+
+func TestStreamAnthropicErrorsWhenStreamEndsBeforeMessageStop(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n"))
+ }))
+ defer upstream.Close()
+
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic, BaseURL: upstream.URL}
+ result := StreamAnthropic(t.Context(), model, ai.Context{}, AnthropicOptions{StreamOptions: ai.StreamOptions{APIKey: "key"}}).Result()
+ if result.StopReason != ai.StopReasonError || !strings.Contains(result.ErrorMessage, "message_stop") {
+ t.Fatalf("expected missing message_stop error, got %#v", result)
+ }
+}
+
func TestAnthropicStreamStatePreservesToolInputFromContentBlockStart(t *testing.T) {
stream := ai.NewAssistantMessageEventStream()
model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
@@ -34,6 +67,239 @@ func TestAnthropicStreamStatePreservesToolInputFromContentBlockStart(t *testing.
if blocks[0].Arguments["path"] != "README.md" {
t.Fatalf("expected tool input from content block start, got %#v", blocks[0].Arguments)
}
+ var sawArgs bool
+ for _, event := range drainAssistantEvents(stream) {
+ if event.Type == "toolcall_delta" && event.Delta == `{"path":"README.md"}` {
+ sawArgs = true
+ }
+ }
+ if !sawArgs {
+ t.Fatalf("expected initial Anthropic tool input to stream as args")
+ }
+}
+
+func TestConvertAnthropicMessagesReplaysEmptyToolInputAsObject(t *testing.T) {
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
+ messages := ConvertAnthropicMessages(model, ai.Context{Messages: []ai.Message{
+ {
+ Role: "assistant",
+ API: model.API,
+ Provider: model.Provider,
+ Model: model.ID,
+ Content: []ai.ContentBlock{
+ {Type: "toolCall", ID: "toolu_1", Name: "get_session"},
+ },
+ },
+ {
+ Role: "toolResult",
+ ToolCallID: "toolu_1",
+ Content: []ai.ContentBlock{{Type: "text", Text: "ok"}},
+ },
+ }}, false, nil)
+
+ blocks, ok := messages[0]["content"].([]map[string]any)
+ if !ok || len(blocks) != 1 {
+ t.Fatalf("expected assistant tool_use block, got %#v", messages)
+ }
+ input, ok := blocks[0]["input"].(map[string]any)
+ if !ok || input == nil || len(input) != 0 {
+ t.Fatalf("expected empty tool input object, got %#v", blocks[0]["input"])
+ }
+}
+
+func TestAnthropicStreamStateStreamsRedactedThinkingImmediately(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
+ output := newAssistant(model)
+ state := newAnthropicStreamState()
+
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(0),
+ "content_block": map[string]any{
+ "type": "redacted_thinking",
+ "data": "signature",
+ },
+ })
+
+ events := drainAssistantEvents(stream)
+ var sawStart, sawDelta bool
+ for _, event := range events {
+ if event.Type == "thinking_start" {
+ sawStart = true
+ }
+ if event.Type == "thinking_delta" && event.Delta == "[Reasoning redacted]" {
+ sawDelta = true
+ }
+ }
+ if !sawStart || !sawDelta {
+ t.Fatalf("expected redacted thinking to stream immediately, got %#v", events)
+ }
+}
+
+func TestAnthropicStreamStateMapsNativeWebSearchToToolActivity(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
+ output := newAssistant(model)
+ state := newAnthropicStreamState()
+
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(0),
+ "content_block": map[string]any{
+ "type": "server_tool_use",
+ "id": "srvtoolu_1",
+ "name": "web_search",
+ },
+ })
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_delta",
+ "index": float64(0),
+ "delta": map[string]any{"type": "input_json_delta", "partial_json": `{"query":"latest headlines Amsterdam news today"}`},
+ })
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(1),
+ "content_block": map[string]any{
+ "type": "web_search_tool_result",
+ "tool_use_id": "srvtoolu_1",
+ "content": []any{map[string]any{
+ "type": "web_search_result",
+ "url": "https://example.com/news",
+ "title": "Amsterdam News",
+ "page_age": "June 1, 2026",
+ }},
+ },
+ })
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_stop",
+ "index": float64(1),
+ })
+
+ events := drainAssistantEvents(stream)
+ if len(output.Content.([]ai.ContentBlock)) != 0 {
+ t.Fatalf("native web search must not become an executable tool block, got %#v", output.Content)
+ }
+ var sawStart, sawArgs, sawResult bool
+ for _, event := range events {
+ switch event.Type {
+ case "toolcall_start":
+ sawStart = event.ToolCall != nil && event.ToolCall.ID == "srvtoolu_1" && event.ToolCall.Name == "web_search"
+ case "toolcall_delta":
+ sawArgs = event.ToolCall != nil && event.ToolCall.Arguments["query"] == "latest headlines Amsterdam news today"
+ case "toolresult":
+ sawResult = event.ToolCall != nil && event.ToolCall.ID == "srvtoolu_1"
+ output, _ := event.CustomValue.(map[string]any)
+ results, _ := output["results"].([]map[string]any)
+ if output["status"] != "success" || output["native"] != true || len(results) != 1 || results[0]["title"] != "Amsterdam News" {
+ t.Fatalf("unexpected native search result payload %#v", event.CustomValue)
+ }
+ }
+ }
+ if !sawStart || !sawArgs || !sawResult {
+ t.Fatalf("expected native web search lifecycle events, got %#v", events)
+ }
+}
+
+func TestAnthropicStreamStateMapsNativeWebFetchError(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
+ output := newAssistant(model)
+ state := newAnthropicStreamState()
+
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(0),
+ "content_block": map[string]any{
+ "type": "server_tool_use",
+ "id": "srvtoolu_fetch",
+ "name": "web_fetch",
+ "input": map[string]any{"url": "https://example.com/article"},
+ },
+ })
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(1),
+ "content_block": map[string]any{
+ "type": "web_fetch_tool_result",
+ "tool_use_id": "srvtoolu_fetch",
+ "content": map[string]any{
+ "type": "web_fetch_tool_error",
+ "error_code": "url_not_accessible",
+ },
+ },
+ })
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_stop",
+ "index": float64(1),
+ })
+
+ events := drainAssistantEvents(stream)
+ var sawResult bool
+ for _, event := range events {
+ if event.Type != "toolresult" {
+ continue
+ }
+ sawResult = event.ToolCall != nil && event.ToolCall.Name == "fetch"
+ output, _ := event.CustomValue.(map[string]any)
+ if output["status"] != "failed" || output["reason"] != "url_not_accessible" {
+ t.Fatalf("unexpected native fetch error payload %#v", event.CustomValue)
+ }
+ }
+ if !sawResult {
+ t.Fatalf("expected native fetch error result, got %#v", events)
+ }
+}
+
+func TestAnthropicStreamStateMapsNativeWebFetchDocumentTitle(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
+ output := newAssistant(model)
+ state := newAnthropicStreamState()
+
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(0),
+ "content_block": map[string]any{
+ "type": "server_tool_use",
+ "id": "srvtoolu_fetch",
+ "name": "web_fetch",
+ "input": map[string]any{"url": "https://example.com/article"},
+ },
+ })
+ state.apply(stream, &output, model, ai.Context{}, false, map[string]any{
+ "type": "content_block_start",
+ "index": float64(1),
+ "content_block": map[string]any{
+ "type": "web_fetch_tool_result",
+ "tool_use_id": "srvtoolu_fetch",
+ "content": map[string]any{
+ "type": "web_fetch_result",
+ "url": "https://example.com/article",
+ "content": map[string]any{
+ "type": "document",
+ "source": map[string]any{
+ "type": "text",
+ "media_type": "text/plain",
+ "data": "Fetched Article Title\n\nBody text",
+ },
+ },
+ },
+ },
+ })
+
+ events := drainAssistantEvents(stream)
+ for _, event := range events {
+ if event.Type != "toolresult" {
+ continue
+ }
+ output, _ := event.CustomValue.(map[string]any)
+ if output["title"] != "Fetched Article Title" {
+ t.Fatalf("expected title from document source, got %#v", event.CustomValue)
+ }
+ return
+ }
+ t.Fatalf("expected native fetch result, got %#v", events)
}
func TestAnthropicBeeperProxyUsesBearerAuth(t *testing.T) {
@@ -46,3 +312,64 @@ func TestAnthropicBeeperProxyUsesBearerAuth(t *testing.T) {
t.Fatalf("did not expect upstream Anthropic API key header for Beeper proxy: %#v", headers)
}
}
+
+func TestAnthropicBeeperProxyForwardsSessionAffinityWhenSupported(t *testing.T) {
+ model := ai.Model{
+ ID: "claude-test",
+ API: ai.ApiAnthropicMessages,
+ Provider: ai.ProviderAnthropic,
+ BaseURL: "https://ai-services.beeper.localtest.me/proxy/anthropic",
+ Compat: map[string]any{"sendSessionAffinityHeaders": true},
+ }
+ headers := anthropicHeaders(model, ai.Context{}, AnthropicOptions{StreamOptions: ai.StreamOptions{APIKey: "matrix-token", SessionID: "session-1"}}, false)
+ if headers["X-Session-Affinity"] != "session-1" {
+ t.Fatalf("expected forwarded session affinity, got %#v", headers)
+ }
+
+ disabled := anthropicHeaders(model, ai.Context{}, AnthropicOptions{StreamOptions: ai.StreamOptions{APIKey: "matrix-token", SessionID: "session-1", CacheRetention: ai.CacheRetentionNone}}, false)
+ if _, ok := disabled["X-Session-Affinity"]; ok {
+ t.Fatalf("cacheRetention=none should suppress session affinity, got %#v", disabled)
+ }
+}
+
+func TestAnthropicHeadersIncludeWebFetchBetaFromMetadata(t *testing.T) {
+ model := ai.Model{ID: "claude-test", API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic}
+ headers := anthropicHeaders(model, ai.Context{}, AnthropicOptions{StreamOptions: ai.StreamOptions{
+ APIKey: "token",
+ Metadata: map[string]any{webFetchBetaMetadataKey: true},
+ }}, false)
+ if !strings.Contains(headers["Anthropic-Beta"], webFetchBeta) || !strings.Contains(headers["Anthropic-Beta"], interleavedThinkingBeta) {
+ t.Fatalf("expected web fetch beta to compose with generated betas, got %#v", headers)
+ }
+}
+
+func TestAnthropicOpus47PlusUsesAdaptiveThinkingByDefault(t *testing.T) {
+ model := ai.Model{
+ ID: "anthropic/claude-opus-4.8",
+ API: ai.ApiAnthropicMessages,
+ Provider: ai.ProviderAnthropic,
+ Reasoning: true,
+ ReasoningMode: "adaptive",
+ MaxTokens: 128000,
+ }
+ if !getAnthropicCompat(model).ForceAdaptiveThinking {
+ t.Fatal("expected catalog adaptive reasoning mode to force adaptive thinking")
+ }
+
+ enabled := true
+ params := BuildAnthropicParams(model, ai.Context{Messages: []ai.Message{{Role: "user", Content: "hi"}}}, false, AnthropicOptions{
+ ThinkingEnabled: &enabled,
+ Effort: "high",
+ })
+ thinking, ok := params["thinking"].(map[string]any)
+ if !ok || thinking["type"] != "adaptive" {
+ t.Fatalf("expected adaptive thinking params, got %#v", params["thinking"])
+ }
+ if _, ok := thinking["budget_tokens"]; ok {
+ t.Fatalf("adaptive thinking must not include manual budget_tokens: %#v", thinking)
+ }
+ outputConfig, ok := params["output_config"].(map[string]any)
+ if !ok || outputConfig["effort"] != "high" {
+ t.Fatalf("expected adaptive effort output_config, got %#v", params["output_config"])
+ }
+}
diff --git a/pkg/ai/providers/citations.go b/pkg/ai/providers/citations.go
new file mode 100644
index 00000000..2d6d3f6e
--- /dev/null
+++ b/pkg/ai/providers/citations.go
@@ -0,0 +1,305 @@
+package providers
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ ai "github.com/beeper/ai-bridge/pkg/ai"
+)
+
+func providerCitationsFromAny(value any, provider ai.Provider, contentIndex int) []ai.Citation {
+ out := []ai.Citation{}
+ seen := map[string]struct{}{}
+ add := func(citation ai.Citation) {
+ key := fmt.Sprintf("%s|%s|%s|%s", citation.URL, citation.RawType, intPointerKey(citation.StartIndex), intPointerKey(citation.EndIndex))
+ if _, ok := seen[key]; ok {
+ return
+ }
+ seen[key] = struct{}{}
+ out = append(out, citation)
+ }
+ if data, ok := value.(map[string]any); ok {
+ for _, citation := range googleGroundingCitationsFromMap(data, provider, contentIndex) {
+ add(citation)
+ }
+ for _, citation := range googleURLContextCitationsFromMap(data, provider, contentIndex) {
+ add(citation)
+ }
+ }
+ walkProviderCitationMaps(value, func(item map[string]any) {
+ if citation, ok := providerCitationFromMap(item, provider, contentIndex); ok {
+ add(citation)
+ }
+ })
+ return out
+}
+
+func intPointerKey(value *int) string {
+ if value == nil {
+ return ""
+ }
+ return strconv.Itoa(*value)
+}
+
+func googleGroundingCitationsFromMap(data map[string]any, provider ai.Provider, contentIndex int) []ai.Citation {
+ metadata, _ := data["groundingMetadata"].(map[string]any)
+ if metadata == nil {
+ metadata, _ = data["grounding_metadata"].(map[string]any)
+ }
+ if metadata == nil {
+ if candidates, _ := data["candidates"].([]any); len(candidates) > 0 {
+ if candidate, _ := candidates[0].(map[string]any); candidate != nil {
+ return googleGroundingCitationsFromMap(candidate, provider, contentIndex)
+ }
+ }
+ return nil
+ }
+ chunks, _ := metadata["groundingChunks"].([]any)
+ if chunks == nil {
+ chunks, _ = metadata["grounding_chunks"].([]any)
+ }
+ if len(chunks) == 0 {
+ return nil
+ }
+ chunkCitations := make([]ai.Citation, 0, len(chunks))
+ for _, rawChunk := range chunks {
+ chunk, _ := rawChunk.(map[string]any)
+ web, _ := chunk["web"].(map[string]any)
+ if web == nil {
+ web, _ = chunk["retrievedContext"].(map[string]any)
+ }
+ if web == nil {
+ chunkCitations = append(chunkCitations, ai.Citation{})
+ continue
+ }
+ url := firstCitationString(stringFromAny(web["uri"]), stringFromAny(web["url"]))
+ chunkCitations = append(chunkCitations, ai.Citation{
+ Type: "url_citation",
+ URL: url,
+ Title: stringFromAny(web["title"]),
+ ContentIndex: &contentIndex,
+ Provider: string(provider),
+ RawType: "grounding",
+ })
+ }
+ supports, _ := metadata["groundingSupports"].([]any)
+ if supports == nil {
+ supports, _ = metadata["grounding_supports"].([]any)
+ }
+ if len(supports) == 0 {
+ out := []ai.Citation{}
+ for _, citation := range chunkCitations {
+ if citation.URL != "" {
+ out = append(out, citation)
+ }
+ }
+ return out
+ }
+ out := []ai.Citation{}
+ for _, rawSupport := range supports {
+ support, _ := rawSupport.(map[string]any)
+ segment, _ := support["segment"].(map[string]any)
+ indices := citationIndexList(firstCitationAny(support, "groundingChunkIndices", "grounding_chunk_indices"))
+ for _, index := range indices {
+ if index < 0 || index >= len(chunkCitations) || chunkCitations[index].URL == "" {
+ continue
+ }
+ citation := chunkCitations[index]
+ if start, ok := intFromCitationAny(firstCitationAny(segment, "startIndex", "start_index")); ok {
+ citation.StartIndex = &start
+ }
+ if end, ok := intFromCitationAny(firstCitationAny(segment, "endIndex", "end_index")); ok {
+ citation.EndIndex = &end
+ }
+ citation.Text = stringFromAny(segment["text"])
+ out = append(out, citation)
+ }
+ }
+ return out
+}
+
+func googleURLContextCitationsFromMap(data map[string]any, provider ai.Provider, contentIndex int) []ai.Citation {
+ metadata, _ := data["urlContextMetadata"].(map[string]any)
+ if metadata == nil {
+ metadata, _ = data["url_context_metadata"].(map[string]any)
+ }
+ if metadata == nil {
+ if candidates, _ := data["candidates"].([]any); len(candidates) > 0 {
+ if candidate, _ := candidates[0].(map[string]any); candidate != nil {
+ return googleURLContextCitationsFromMap(candidate, provider, contentIndex)
+ }
+ }
+ return nil
+ }
+ urls, _ := metadata["urlMetadata"].([]any)
+ if urls == nil {
+ urls, _ = metadata["url_metadata"].([]any)
+ }
+ out := []ai.Citation{}
+ for _, rawURL := range urls {
+ item, _ := rawURL.(map[string]any)
+ if item == nil {
+ continue
+ }
+ url := firstCitationString(stringFromAny(item["retrievedUrl"]), stringFromAny(item["retrieved_url"]), stringFromAny(item["url"]))
+ if url == "" {
+ continue
+ }
+ out = append(out, ai.Citation{
+ Type: "url_citation",
+ URL: url,
+ ContentIndex: &contentIndex,
+ Provider: string(provider),
+ RawType: "url_context",
+ })
+ }
+ return out
+}
+
+func citationIndexList(value any) []int {
+ raw, ok := value.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]int, 0, len(raw))
+ for _, item := range raw {
+ if index, ok := intFromCitationAny(item); ok {
+ out = append(out, index)
+ }
+ }
+ return out
+}
+
+func walkProviderCitationMaps(value any, emit func(map[string]any)) {
+ switch typed := value.(type) {
+ case nil:
+ return
+ case map[string]any:
+ emit(typed)
+ for _, item := range typed {
+ switch item.(type) {
+ case map[string]any, []any:
+ walkProviderCitationMaps(item, emit)
+ }
+ }
+ case []any:
+ for _, item := range typed {
+ walkProviderCitationMaps(item, emit)
+ }
+ }
+}
+
+func providerCitationFromMap(data map[string]any, provider ai.Provider, contentIndex int) (ai.Citation, bool) {
+ rawType := strings.ToLower(firstCitationString(stringFromAny(data["rawType"]), stringFromAny(data["type"])))
+ citationData := data
+ if nested, _ := data["url_citation"].(map[string]any); nested != nil {
+ citationData = mergeCitationMaps(data, nested)
+ } else if nested, _ := data["urlCitation"].(map[string]any); nested != nil {
+ citationData = mergeCitationMaps(data, nested)
+ }
+ rawType = firstCitationString(
+ strings.ToLower(firstCitationString(stringFromAny(citationData["rawType"]), stringFromAny(citationData["type"]))),
+ rawType,
+ )
+ url := firstCitationString(stringFromAny(citationData["url"]), stringFromAny(citationData["uri"]))
+ if url == "" || (!strings.Contains(rawType, "citation") && rawType != "web_search_result_location" && rawType != "web_search_result" && rawType != "web_fetch_result" && rawType != "openrouter:web_fetch") {
+ return ai.Citation{}, false
+ }
+ title := stringFromAny(citationData["title"])
+ if title == "" && (rawType == "web_fetch_result" || rawType == "openrouter:web_fetch") {
+ if content, _ := citationData["content"].(map[string]any); content != nil {
+ title = stringFromAny(content["title"])
+ if title == "" {
+ title = documentTitleFromProviderContent(content)
+ }
+ }
+ }
+ resolvedContentIndex := contentIndex
+ if index, ok := intFromCitationAny(firstCitationAny(citationData, "contentIndex", "content_index", "outputIndex", "output_index")); ok {
+ resolvedContentIndex = index
+ }
+ citation := ai.Citation{
+ Type: "url_citation",
+ URL: url,
+ Title: title,
+ Description: firstCitationString(stringFromAny(citationData["description"]), stringFromAny(citationData["summary"])),
+ SiteName: firstCitationString(stringFromAny(citationData["siteName"]), stringFromAny(citationData["site_name"])),
+ FaviconURL: firstCitationString(stringFromAny(citationData["faviconUrl"]), stringFromAny(citationData["favicon_url"])),
+ ImageURL: firstCitationString(stringFromAny(citationData["imageUrl"]), stringFromAny(citationData["image_url"])),
+ PublishedAt: firstCitationString(stringFromAny(citationData["publishedAt"]), stringFromAny(citationData["published_at"]), stringFromAny(citationData["publishedDate"]), stringFromAny(citationData["datePublished"]), stringFromAny(citationData["date"])),
+ ContentIndex: &resolvedContentIndex,
+ Provider: string(provider),
+ RawType: rawType,
+ Text: firstCitationString(stringFromAny(citationData["text"]), stringFromAny(citationData["cited_text"])),
+ }
+ if start, ok := intFromCitationAny(firstCitationAny(citationData, "startIndex", "start_index")); ok {
+ citation.StartIndex = &start
+ }
+ if end, ok := intFromCitationAny(firstCitationAny(citationData, "endIndex", "end_index")); ok {
+ citation.EndIndex = &end
+ }
+ return citation, true
+}
+
+func documentTitleFromProviderContent(content map[string]any) string {
+ source, _ := content["source"].(map[string]any)
+ text := firstCitationString(stringFromAny(content["text"]), stringFromAny(content["data"]), stringFromAny(source["data"]))
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ return line
+ }
+ }
+ return ""
+}
+
+func mergeCitationMaps(first map[string]any, second map[string]any) map[string]any {
+ out := map[string]any{}
+ for key, value := range first {
+ out[key] = value
+ }
+ for key, value := range second {
+ out[key] = value
+ }
+ return out
+}
+
+func firstCitationAny(data map[string]any, keys ...string) any {
+ for _, key := range keys {
+ if value, ok := data[key]; ok {
+ return value
+ }
+ }
+ return nil
+}
+
+func firstCitationString(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func intFromCitationAny(value any) (int, bool) {
+ switch typed := value.(type) {
+ case int:
+ return typed, true
+ case int64:
+ return int(typed), true
+ case float64:
+ return int(typed), true
+ case string:
+ parsed, err := strconv.Atoi(strings.TrimSpace(typed))
+ return parsed, err == nil
+ default:
+ if value != nil {
+ if parsed, err := strconv.Atoi(fmt.Sprint(value)); err == nil {
+ return parsed, true
+ }
+ }
+ return 0, false
+ }
+}
diff --git a/pkg/ai/providers/google.go b/pkg/ai/providers/google.go
index 907169bb..ff4beeb7 100644
--- a/pkg/ai/providers/google.go
+++ b/pkg/ai/providers/google.go
@@ -97,6 +97,11 @@ func StreamGoogle(ctx context.Context, model ai.Model, llmContext ai.Context, op
if err := json.Unmarshal([]byte(sse.Data), &chunk); err != nil {
return fmt.Errorf("could not parse Google SSE event: %w; data=%s; raw=%s", err, sse.Data, strings.Join(sse.Raw, "\n"))
}
+ citations := providerCitationsFromAny(chunk, model.Provider, max(0, state.currentBlockIndex))
+ if len(citations) > 0 {
+ output.Citations = append(output.Citations, citations...)
+ stream.Push(ai.AssistantMessageEvent{Type: "source", Partial: &output})
+ }
state.apply(stream, &output, model, chunk)
return nil
})
diff --git a/pkg/ai/providers/google_vertex.go b/pkg/ai/providers/google_vertex.go
index fee5963e..972e9a0a 100644
--- a/pkg/ai/providers/google_vertex.go
+++ b/pkg/ai/providers/google_vertex.go
@@ -107,6 +107,11 @@ func StreamGoogleVertex(ctx context.Context, model ai.Model, llmContext ai.Conte
if err := json.Unmarshal([]byte(sse.Data), &chunk); err != nil {
return fmt.Errorf("could not parse Google Vertex SSE event: %w; data=%s; raw=%s", err, sse.Data, strings.Join(sse.Raw, "\n"))
}
+ citations := providerCitationsFromAny(chunk, model.Provider, max(0, state.currentBlockIndex))
+ if len(citations) > 0 {
+ output.Citations = append(output.Citations, citations...)
+ stream.Push(ai.AssistantMessageEvent{Type: "source", Partial: &output})
+ }
state.apply(stream, &output, model, chunk)
return nil
})
diff --git a/pkg/ai/providers/images/register_builtins_test.go b/pkg/ai/providers/images/register_builtins_test.go
deleted file mode 100644
index 9e69ac11..00000000
--- a/pkg/ai/providers/images/register_builtins_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package images
-
-import (
- "testing"
-
- ai "github.com/beeper/ai-bridge/pkg/ai"
-)
-
-func TestImageModelRegistryOnlyExposesRegisteredAPIs(t *testing.T) {
- RegisterBuiltInImagesAPIProviders()
- for _, provider := range ai.GetImageProviders() {
- for _, model := range ai.GetImageModels(provider) {
- if _, ok := ai.GetImagesAPIProvider(model.API); !ok {
- t.Fatalf("image model %s/%s uses unregistered api %s", provider, model.ID, model.API)
- }
- }
- }
-}
diff --git a/pkg/ai/providers/openai_codex_responses.go b/pkg/ai/providers/openai_codex_responses.go
index 19943a15..70a7861e 100644
--- a/pkg/ai/providers/openai_codex_responses.go
+++ b/pkg/ai/providers/openai_codex_responses.go
@@ -672,7 +672,7 @@ func BuildCodexSSEHeaders(modelHeaders map[string]string, additionalHeaders map[
headers["accept"] = "text/event-stream"
headers["content-type"] = "application/json"
if sessionID != "" {
- headers["session_id"] = sessionID
+ headers["session-id"] = sessionID
headers["x-client-request-id"] = sessionID
}
return headers
@@ -686,7 +686,7 @@ func BuildCodexWebSocketHeaders(modelHeaders map[string]string, additionalHeader
delete(headers, "openai-beta")
headers["OpenAI-Beta"] = openAIBetaResponsesWebSockets
headers["x-client-request-id"] = requestID
- headers["session_id"] = requestID
+ headers["session-id"] = requestID
return headers
}
diff --git a/pkg/ai/providers/openai_codex_responses_test.go b/pkg/ai/providers/openai_codex_responses_test.go
index e708c0fe..3a94dac9 100644
--- a/pkg/ai/providers/openai_codex_responses_test.go
+++ b/pkg/ai/providers/openai_codex_responses_test.go
@@ -104,11 +104,11 @@ func TestCodexAccountAndHeaders(t *testing.T) {
if headers["Authorization"] != "Bearer "+token || headers["chatgpt-account-id"] != "acct_123" || headers["OpenAI-Beta"] != "responses=experimental" {
t.Fatalf("unexpected sse headers %#v", headers)
}
- if headers["session_id"] != "session-1" || headers["x-client-request-id"] != "session-1" {
+ if headers["session-id"] != "session-1" || headers["x-client-request-id"] != "session-1" {
t.Fatalf("expected session headers, got %#v", headers)
}
wsHeaders := BuildCodexWebSocketHeaders(headers, nil, accountID, token, "request-1")
- if wsHeaders["OpenAI-Beta"] != openAIBetaResponsesWebSockets || wsHeaders["session_id"] != "request-1" {
+ if wsHeaders["OpenAI-Beta"] != openAIBetaResponsesWebSockets || wsHeaders["session-id"] != "request-1" {
t.Fatalf("unexpected websocket headers %#v", wsHeaders)
}
if _, ok := wsHeaders["accept"]; ok {
diff --git a/pkg/ai/providers/openai_completions.go b/pkg/ai/providers/openai_completions.go
index 4519a4c2..736d79f5 100644
--- a/pkg/ai/providers/openai_completions.go
+++ b/pkg/ai/providers/openai_completions.go
@@ -205,7 +205,9 @@ func applyCompleteOpenAICompletions(output *ai.Message, model ai.Model, raw map[
}
}
if text := textFromCompletionMessage(message["content"]); text != "" {
+ contentIndex := len(blocks)
blocks = append(blocks, ai.ContentBlock{Type: "text", Text: text})
+ output.Citations = append(output.Citations, providerCitationsFromAny(message, model.Provider, contentIndex)...)
}
if toolCalls, ok := message["tool_calls"].([]any); ok {
for _, rawToolCall := range toolCalls {
@@ -535,12 +537,20 @@ func buildOpenAIClientConfig(model ai.Model, llmContext ai.Context, options ai.S
}
}
if options.SessionID != "" && resolveCacheRetention(options.CacheRetention) != ai.CacheRetentionNone {
- headers["x-client-request-id"] = options.SessionID
- if model.API == ai.ApiOpenAIResponses || ResolveOpenAICompletionsCompat(model).SendSessionAffinityHeaders {
- headers["session_id"] = options.SessionID
- }
- if model.API == ai.ApiOpenAICompletions && ResolveOpenAICompletionsCompat(model).SendSessionAffinityHeaders {
- headers["x-session-affinity"] = options.SessionID
+ switch model.API {
+ case ai.ApiOpenAIResponses:
+ compat := ResolveOpenAIResponsesCompat(model)
+ headers["x-client-request-id"] = options.SessionID
+ if compat.SendSessionIDHeader {
+ headers["session_id"] = options.SessionID
+ }
+ case ai.ApiOpenAICompletions:
+ compat := ResolveOpenAICompletionsCompat(model)
+ headers["x-client-request-id"] = options.SessionID
+ if compat.SendSessionAffinityHeaders {
+ headers["session_id"] = options.SessionID
+ headers["x-session-affinity"] = options.SessionID
+ }
}
}
for key, value := range options.Headers {
diff --git a/pkg/ai/providers/openai_conversion_test.go b/pkg/ai/providers/openai_conversion_test.go
index 833b0c57..9de35844 100644
--- a/pkg/ai/providers/openai_conversion_test.go
+++ b/pkg/ai/providers/openai_conversion_test.go
@@ -98,6 +98,32 @@ func TestCompleteOpenAIResponsesUsesNonStreamingPayload(t *testing.T) {
}
}
+func TestCompleteOpenAIResponsesExtractsOpenRouterWebFetchSources(t *testing.T) {
+ model := ai.Model{ID: "x-ai/grok-4.20", API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenRouter}
+ output := newAssistant(model)
+ applyCompleteOpenAIResponses(&output, model, OpenAIResponsesOptions{}, map[string]any{
+ "id": "resp_1",
+ "status": "completed",
+ "output": []any{map[string]any{
+ "type": "openrouter:web_fetch",
+ "id": "st_1",
+ "status": "completed",
+ "url": "https://example.com/fetched",
+ "title": "Fetched Page",
+ }, map[string]any{
+ "type": "message",
+ "id": "msg_1",
+ "content": []any{map[string]any{
+ "type": "output_text",
+ "text": "ok",
+ }},
+ }},
+ })
+ if len(output.Citations) != 1 || output.Citations[0].URL != "https://example.com/fetched" || output.Citations[0].Title != "Fetched Page" {
+ t.Fatalf("expected OpenRouter fetch source from non-stream response, got %#v", output.Citations)
+ }
+}
+
func TestConvertCompletionsMessagesIncludesNativeAudio(t *testing.T) {
model := ai.Model{ID: "gpt-audio", API: ai.ApiOpenAICompletions, Provider: "openai", Input: []string{"text", "audio"}}
messages := ConvertCompletionsMessages(model, ai.Context{
@@ -117,6 +143,138 @@ func TestConvertCompletionsMessagesIncludesNativeAudio(t *testing.T) {
}
}
+func TestProviderCitationsFromOpenAIAnnotations(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "type": "message",
+ "content": []any{map[string]any{
+ "type": "output_text",
+ "text": "hello citation",
+ "annotations": []any{map[string]any{
+ "type": "url_citation",
+ "start_index": float64(6),
+ "end_index": float64(14),
+ "url": "https://example.com/source",
+ "title": "Source",
+ }},
+ }},
+ }, ai.ProviderOpenAI, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/source" || citations[0].Title != "Source" {
+ t.Fatalf("unexpected citations %#v", citations)
+ }
+ if citations[0].StartIndex == nil || *citations[0].StartIndex != 6 || citations[0].EndIndex == nil || *citations[0].EndIndex != 14 {
+ t.Fatalf("missing citation range %#v", citations[0])
+ }
+}
+
+func TestProviderCitationsFromGoogleGrounding(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "candidates": []any{map[string]any{
+ "groundingMetadata": map[string]any{
+ "groundingChunks": []any{map[string]any{
+ "web": map[string]any{"uri": "https://example.com/google", "title": "Google Source"},
+ }},
+ "groundingSupports": []any{map[string]any{
+ "segment": map[string]any{"startIndex": float64(2), "endIndex": float64(8), "text": "claim"},
+ "groundingChunkIndices": []any{float64(0)},
+ }},
+ },
+ }},
+ }, ai.ProviderGoogle, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/google" || citations[0].Title != "Google Source" {
+ t.Fatalf("unexpected grounding citations %#v", citations)
+ }
+ if citations[0].StartIndex == nil || *citations[0].StartIndex != 2 || citations[0].EndIndex == nil || *citations[0].EndIndex != 8 || citations[0].Text != "claim" {
+ t.Fatalf("missing grounding citation range %#v", citations[0])
+ }
+}
+
+func TestProviderCitationsFromAnthropicWebSearchLocation(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "type": "web_search_result_location",
+ "url": "https://example.com/anthropic",
+ "title": "Anthropic Source",
+ "cited_text": "quoted source text",
+ }, ai.ProviderAnthropic, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/anthropic" || citations[0].Title != "Anthropic Source" {
+ t.Fatalf("unexpected citations %#v", citations)
+ }
+ if citations[0].Text != "quoted source text" {
+ t.Fatalf("missing cited text %#v", citations[0])
+ }
+}
+
+func TestProviderCitationsFromNativeWebSearchResult(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "type": "web_search_result",
+ "url": "https://example.com/native",
+ "title": "Native Result",
+ }, ai.ProviderAnthropic, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/native" || citations[0].Title != "Native Result" || citations[0].RawType != "web_search_result" {
+ t.Fatalf("unexpected native search citations %#v", citations)
+ }
+}
+
+func TestProviderCitationsPreferNestedCitationType(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "type": "annotation",
+ "url_citation": map[string]any{
+ "type": "url_citation",
+ "url": "https://example.com/nested",
+ "title": "Nested",
+ },
+ }, ai.ProviderOpenAI, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/nested" || citations[0].Title != "Nested" {
+ t.Fatalf("unexpected nested citations %#v", citations)
+ }
+}
+
+func TestProviderCitationsFromAnthropicWebFetchResult(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "type": "web_fetch_tool_result",
+ "content": map[string]any{
+ "type": "web_fetch_result",
+ "url": "https://example.com/article",
+ "content": map[string]any{
+ "type": "document",
+ "title": "Fetched Article",
+ },
+ },
+ }, ai.ProviderAnthropic, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/article" || citations[0].Title != "Fetched Article" || citations[0].RawType != "web_fetch_result" {
+ t.Fatalf("unexpected web fetch citations %#v", citations)
+ }
+}
+
+func TestProviderCitationsFromOpenRouterWebFetchItem(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "type": "openrouter:web_fetch",
+ "status": "completed",
+ "url": "https://example.com/openrouter-fetch",
+ "title": "OpenRouter Fetch",
+ "httpStatus": float64(200),
+ "content": "Fetched page text",
+ }, ai.ProviderOpenRouter, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/openrouter-fetch" || citations[0].Title != "OpenRouter Fetch" || citations[0].RawType != "openrouter:web_fetch" {
+ t.Fatalf("unexpected OpenRouter web fetch citations %#v", citations)
+ }
+}
+
+func TestProviderCitationsFromGoogleURLContextMetadata(t *testing.T) {
+ citations := providerCitationsFromAny(map[string]any{
+ "candidates": []any{map[string]any{
+ "urlContextMetadata": map[string]any{
+ "urlMetadata": []any{map[string]any{
+ "retrievedUrl": "https://example.com/url-context",
+ "urlRetrievalStatus": "URL_RETRIEVAL_STATUS_SUCCESS",
+ }},
+ },
+ }},
+ }, ai.ProviderGoogle, 0)
+ if len(citations) != 1 || citations[0].URL != "https://example.com/url-context" || citations[0].RawType != "url_context" {
+ t.Fatalf("unexpected URL context citations %#v", citations)
+ }
+}
+
func TestConvertResponsesMessagesIncludesNativeAudio(t *testing.T) {
model := ai.Model{ID: "gpt-audio", API: ai.ApiOpenAIResponses, Provider: "openai", Input: []string{"text", "audio"}}
messages := ConvertResponsesMessages(model, ai.Context{
@@ -341,6 +499,25 @@ func TestBuildCompletionsParamsUsesDetectedCompat(t *testing.T) {
}
}
+func TestOpenRouterCompletionsCompatDoesNotUseDeveloperRole(t *testing.T) {
+ model := ai.Model{
+ ID: "z-ai/glm-4.5v",
+ API: ai.ApiOpenAICompletions,
+ Provider: ai.ProviderOpenRouter,
+ BaseURL: "https://openrouter.ai/api/v1",
+ Reasoning: true,
+ Input: []string{"text", "image"},
+ }
+ params := BuildCompletionsParams(model, ai.Context{SystemPrompt: "You are concise."}, OpenAICompletionsOptions{})
+ messages := params["messages"].([]map[string]any)
+ if messages[0]["role"] != "system" {
+ t.Fatalf("OpenRouter completions must not use developer role, got %#v", messages[0])
+ }
+ if thinking := params["reasoning"].(map[string]any); thinking["effort"] != "none" {
+ t.Fatalf("expected OpenRouter default reasoning none, got %#v", thinking)
+ }
+}
+
func TestRegisterBuiltInsIncludesOpenAICodexResponses(t *testing.T) {
ResetAPIProviders()
provider, ok := ai.GetAPIProvider(ai.ApiOpenAICodexResponses)
@@ -352,17 +529,6 @@ func TestRegisterBuiltInsIncludesOpenAICodexResponses(t *testing.T) {
}
}
-func TestModelRegistryOnlyExposesRegisteredTextAPIs(t *testing.T) {
- ResetAPIProviders()
- for _, provider := range ai.GetProviders() {
- for _, model := range ai.GetModels(provider) {
- if _, ok := ai.GetAPIProvider(model.API); !ok {
- t.Fatalf("model %s/%s uses unregistered api %s", provider, model.ID, model.API)
- }
- }
- }
-}
-
func TestSimpleReasoningEffortClampsAndOmitsOff(t *testing.T) {
off := "none"
xhigh := "extra"
@@ -420,9 +586,61 @@ func TestOpenAIClientConfigResolvesCloudflareGatewayHeaders(t *testing.T) {
if config.Headers["x-model"] != "1" || config.Headers["x-extra"] != "2" {
t.Fatalf("expected merged headers, got %#v", config.Headers)
}
+ if config.Headers["x-client-request-id"] != "session-1" {
+ t.Fatalf("default completions compat should send request id header, got %#v", config.Headers)
+ }
+}
+
+func TestOpenAIClientConfigResponsesHonorsSessionIDCompat(t *testing.T) {
+ config, err := buildOpenAIClientConfig(ai.Model{
+ API: ai.ApiOpenAIResponses,
+ Provider: ai.ProviderOpenAI,
+ BaseURL: "https://api.openai.com/v1",
+ Compat: map[string]any{"sendSessionIdHeader": false},
+ }, ai.Context{}, ai.StreamOptions{APIKey: "token", SessionID: "session-1"})
+ if err != nil {
+ t.Fatal(err)
+ }
if config.Headers["x-client-request-id"] != "session-1" {
t.Fatalf("expected request id header, got %#v", config.Headers)
}
+ if _, ok := config.Headers["session_id"]; ok {
+ t.Fatalf("sendSessionIdHeader=false should suppress session_id, got %#v", config.Headers)
+ }
+}
+
+func TestOpenAIClientConfigCompletionsAffinityHeadersRequireCompat(t *testing.T) {
+ defaultConfig, err := buildOpenAIClientConfig(ai.Model{
+ API: ai.ApiOpenAICompletions,
+ Provider: ai.ProviderOpenRouter,
+ BaseURL: "https://openrouter.ai/api/v1",
+ }, ai.Context{}, ai.StreamOptions{APIKey: "token", SessionID: "session-1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"session_id", "x-session-affinity"} {
+ if _, ok := defaultConfig.Headers[key]; ok {
+ t.Fatalf("default completions compat should not send %s, got %#v", key, defaultConfig.Headers)
+ }
+ }
+ if defaultConfig.Headers["x-client-request-id"] != "session-1" {
+ t.Fatalf("default completions compat should send request id header, got %#v", defaultConfig.Headers)
+ }
+
+ enabledConfig, err := buildOpenAIClientConfig(ai.Model{
+ API: ai.ApiOpenAICompletions,
+ Provider: ai.ProviderOpenRouter,
+ BaseURL: "https://openrouter.ai/api/v1",
+ Compat: map[string]any{"sendSessionAffinityHeaders": true},
+ }, ai.Context{}, ai.StreamOptions{APIKey: "token", SessionID: "session-1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"session_id", "x-client-request-id", "x-session-affinity"} {
+ if enabledConfig.Headers[key] != "session-1" {
+ t.Fatalf("expected %s=session-1, got %#v", key, enabledConfig.Headers)
+ }
+ }
}
func TestOpenAIClientConfigAddsGitHubCopilotDynamicHeaders(t *testing.T) {
diff --git a/pkg/ai/providers/openai_responses.go b/pkg/ai/providers/openai_responses.go
index f7ee4c41..ae1d5688 100644
--- a/pkg/ai/providers/openai_responses.go
+++ b/pkg/ai/providers/openai_responses.go
@@ -177,6 +177,7 @@ func applyCompleteOpenAIResponses(output *ai.Message, model ai.Model, options Op
if id, ok := item["id"].(string); ok && id != "" {
block.TextSignature = mustJSON(map[string]any{"v": 1, "id": id})
}
+ output.Citations = append(output.Citations, providerCitationsFromAny(item, model.Provider, len(blocks))...)
blocks = append(blocks, block)
}
case "function_call":
@@ -185,6 +186,8 @@ func applyCompleteOpenAIResponses(output *ai.Message, model ai.Model, options Op
blocks = append(blocks, ai.ContentBlock{Type: "toolCall", ID: id, Name: fmt.Sprint(item["name"]), Arguments: parseJSONMap(args)})
case "image_generation_call":
blocks = append(blocks, imageBlockFromGenerationItem(item, ai.ContentBlock{}))
+ case "web_search_call", "openrouter:web_search", "openrouter:web_fetch":
+ output.Citations = append(output.Citations, providerCitationsFromAny(item, model.Provider, len(blocks))...)
}
}
}
diff --git a/pkg/ai/providers/openai_shared.go b/pkg/ai/providers/openai_shared.go
index 67f67fc4..128b50db 100644
--- a/pkg/ai/providers/openai_shared.go
+++ b/pkg/ai/providers/openai_shared.go
@@ -130,6 +130,7 @@ func detectOpenAICompletionsCompat(model ai.Model) ResolvedOpenAICompletionsComp
isZai := provider == "zai" || strings.Contains(baseURL, "api.z.ai")
isTogether := provider == "together" || strings.Contains(baseURL, "api.together.ai") || strings.Contains(baseURL, "api.together.xyz")
isMoonshot := provider == "moonshotai" || provider == "moonshotai-cn" || strings.Contains(baseURL, "api.moonshot.")
+ isOpenRouter := provider == "openrouter" || strings.Contains(baseURL, "openrouter.ai")
isCloudflareWorkersAI := provider == "cloudflare-workers-ai" || strings.Contains(baseURL, "api.cloudflare.com")
isCloudflareAIGateway := provider == "cloudflare-ai-gateway" || strings.Contains(baseURL, "gateway.ai.cloudflare.com")
isA8C := provider == "a8c" || strings.Contains(baseURL, "/proxy/a8c/")
@@ -158,7 +159,7 @@ func detectOpenAICompletionsCompat(model ai.Model) ResolvedOpenAICompletionsComp
thinkingFormat = "together"
} else if isA8C {
thinkingFormat = "a8c"
- } else if provider == "openrouter" || strings.Contains(baseURL, "openrouter.ai") {
+ } else if isOpenRouter {
thinkingFormat = "openrouter"
}
maxTokensField := "max_completion_tokens"
@@ -171,7 +172,7 @@ func detectOpenAICompletionsCompat(model ai.Model) ResolvedOpenAICompletionsComp
}
return ResolvedOpenAICompletionsCompat{
SupportsStore: !isNonStandard,
- SupportsDeveloperRole: !isNonStandard,
+ SupportsDeveloperRole: !isNonStandard && !isOpenRouter,
SupportsReasoningEffort: !isGrok && !isZai && !isMoonshot && !isTogether && !isCloudflareAIGateway,
SupportsUsageInStreaming: true,
MaxTokensField: maxTokensField,
@@ -472,6 +473,11 @@ func (s *completionsStreamState) apply(stream *ai.AssistantMessageEventStream, o
output.Content = s.blocks
stream.Push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: index, Delta: text, Partial: output})
}
+ if annotations, ok := delta["annotations"].([]any); ok {
+ index := s.ensureText(stream, output)
+ output.Citations = append(output.Citations, providerCitationsFromAny(annotations, model.Provider, index)...)
+ stream.Push(ai.AssistantMessageEvent{Type: "source", Partial: output})
+ }
for _, field := range []string{"reasoning_content", "reasoning", "reasoning_text"} {
if reasoning, ok := delta[field].(string); ok && reasoning != "" {
signature := field
@@ -743,15 +749,234 @@ func hasToolHistory(messages []ai.Message) bool {
type responsesStreamState struct {
blocks []ai.ContentBlock
currentIndex int
+ currentItemID string
currentItemType string
currentItem map[string]any
currentMessagePartType string
hasReasoningSummaryPart bool
+ itemIndexByID map[string]int
+ itemIDByOutputIndex map[int]string
+ itemTypeByID map[string]string
+ messagePartTypeByID map[string]string
+ reasoningSummaryByID map[string]bool
toolArgsByIndex map[int]string
+ toolArgsByItemID map[string]string
+ nativeToolsByItemID map[string]ai.ToolCall
}
func newResponsesStreamState() *responsesStreamState {
- return &responsesStreamState{currentIndex: -1, toolArgsByIndex: map[int]string{}}
+ return &responsesStreamState{
+ currentIndex: -1,
+ itemIndexByID: map[string]int{},
+ itemIDByOutputIndex: map[int]string{},
+ itemTypeByID: map[string]string{},
+ messagePartTypeByID: map[string]string{},
+ reasoningSummaryByID: map[string]bool{},
+ toolArgsByIndex: map[int]string{},
+ toolArgsByItemID: map[string]string{},
+ nativeToolsByItemID: map[string]ai.ToolCall{},
+ }
+}
+
+func responsesItemID(item map[string]any) string {
+ return strings.TrimSpace(stringFromAny(item["id"]))
+}
+
+func responsesEventItemID(event map[string]any) string {
+ return strings.TrimSpace(stringFromAny(event["item_id"]))
+}
+
+func responsesNativeToolKey(item map[string]any, toolCall ai.ToolCall) string {
+ if id := responsesItemID(item); id != "" {
+ return id
+ }
+ return toolCall.ID
+}
+
+func (s *responsesStreamState) eventItemID(event map[string]any) string {
+ if id := responsesEventItemID(event); id != "" {
+ return id
+ }
+ if outputIndex, ok := event["output_index"]; ok {
+ if id := s.itemIDByOutputIndex[intFromAny(outputIndex)]; id != "" {
+ return id
+ }
+ }
+ return s.currentItemID
+}
+
+func (s *responsesStreamState) eventContentIndex(event map[string]any) int {
+ itemID := s.eventItemID(event)
+ if itemID != "" {
+ if index, ok := s.itemIndexByID[itemID]; ok {
+ return index
+ }
+ }
+ return s.currentIndex
+}
+
+func (s *responsesStreamState) eventItemType(event map[string]any) string {
+ itemID := s.eventItemID(event)
+ if itemID != "" {
+ if itemType := s.itemTypeByID[itemID]; itemType != "" {
+ return itemType
+ }
+ }
+ return s.currentItemType
+}
+
+func (s *responsesStreamState) setItemIndex(itemID, itemType string, index int) {
+ if itemID == "" {
+ return
+ }
+ s.itemIndexByID[itemID] = index
+ s.itemTypeByID[itemID] = itemType
+}
+
+func (s *responsesStreamState) setEventOutputIndex(event map[string]any, itemID string) {
+ if itemID == "" {
+ return
+ }
+ if outputIndex, ok := event["output_index"]; ok {
+ s.itemIDByOutputIndex[intFromAny(outputIndex)] = itemID
+ }
+}
+
+func appendMissingPrefix(current, full string) string {
+ if full == "" || full == current {
+ return ""
+ }
+ if current == "" {
+ return full
+ }
+ if strings.HasPrefix(full, current) {
+ return full[len(current):]
+ }
+ return ""
+}
+
+func responsesPartText(part map[string]any, partType string) string {
+ switch partType {
+ case "refusal":
+ return stringFromAny(part["refusal"])
+ default:
+ return stringFromAny(part["text"])
+ }
+}
+
+func responsesNativeToolCall(item map[string]any, fallbackIndex int, provider ai.Provider) (ai.ToolCall, bool) {
+ name, _, ok := responsesNativeToolInfo(item, provider)
+ if !ok {
+ return ai.ToolCall{}, false
+ }
+ id := responsesItemID(item)
+ if id == "" {
+ id = fmt.Sprintf("native_%s_%d", strings.ReplaceAll(name, "_", "-"), fallbackIndex+1)
+ }
+ return ai.ToolCall{
+ Type: "toolCall",
+ ID: id,
+ Name: name,
+ Arguments: responsesNativeToolArguments(item, name),
+ }, true
+}
+
+func responsesNativeToolInfo(item map[string]any, provider ai.Provider) (toolName string, resultProvider string, ok bool) {
+ itemType := strings.TrimSpace(stringFromAny(item["type"]))
+ switch itemType {
+ case "web_search_call":
+ return "web_search", string(provider), true
+ case "openrouter:web_search":
+ return "web_search", string(ai.ProviderOpenRouter), true
+ case "openrouter:web_fetch":
+ return "fetch", string(ai.ProviderOpenRouter), true
+ default:
+ return "", "", false
+ }
+}
+
+func responsesNativeToolArguments(item map[string]any, toolName string) map[string]any {
+ args := map[string]any{}
+ switch toolName {
+ case "web_search":
+ addNativeWebSearchQuery(args, item)
+ if action, _ := item["action"].(map[string]any); action != nil {
+ addNativeWebSearchQuery(args, action)
+ if queries, ok := action["queries"].([]any); ok && len(queries) > 0 {
+ args["queries"] = queries
+ }
+ if actionType := strings.TrimSpace(stringFromAny(action["type"])); actionType != "" {
+ args["action"] = actionType
+ }
+ }
+ case "fetch":
+ if url := strings.TrimSpace(stringFromAny(item["url"])); url != "" {
+ args["url"] = url
+ }
+ }
+ return args
+}
+
+func addNativeWebSearchQuery(args map[string]any, data map[string]any) {
+ if _, ok := args["query"]; ok {
+ return
+ }
+ for _, key := range []string{"query", "search_query", "searchQuery"} {
+ if value := strings.TrimSpace(stringFromAny(data[key])); value != "" {
+ args["query"] = value
+ return
+ }
+ }
+}
+
+func responsesNativeToolResult(item map[string]any, provider ai.Provider) map[string]any {
+ toolName, resultProvider, _ := responsesNativeToolInfo(item, provider)
+ status := strings.TrimSpace(stringFromAny(item["status"]))
+ result := map[string]any{
+ "state": "complete",
+ "status": "success",
+ "provider": resultProvider,
+ "native": true,
+ }
+ if status != "" {
+ result["providerStatus"] = status
+ }
+ for _, key := range []string{"url", "title", "httpStatus", "http_status"} {
+ if value := item[key]; value != nil {
+ result[key] = value
+ }
+ }
+ if toolName == "web_search" {
+ if results := item["results"]; results != nil {
+ result["results"] = results
+ }
+ }
+ if toolName == "fetch" {
+ if url := stringFromAny(item["url"]); url != "" {
+ result["final_url"] = url
+ }
+ }
+ if args := responsesNativeToolArguments(item, toolName); len(args) > 0 {
+ for key, value := range args {
+ result[key] = value
+ }
+ }
+ if status == "failed" || status == "incomplete" {
+ result["state"] = "error"
+ result["status"] = "failed"
+ if errorData, _ := item["error"].(map[string]any); errorData != nil {
+ if message := strings.TrimSpace(stringFromAny(errorData["message"])); message != "" {
+ result["reason"] = message
+ }
+ }
+ if message := strings.TrimSpace(stringFromAny(item["error"])); message != "" {
+ result["reason"] = message
+ }
+ if result["reason"] == nil {
+ result["reason"] = "Provider-native web tool failed"
+ }
+ }
+ return result
}
func (s *responsesStreamState) apply(stream *ai.AssistantMessageEventStream, output *ai.Message, model ai.Model, options OpenAIResponsesOptions, event map[string]any) {
@@ -772,7 +997,10 @@ func (s *responsesStreamState) apply(stream *ai.AssistantMessageEventStream, out
case "response.output_item.added":
item, _ := event["item"].(map[string]any)
itemType, _ := item["type"].(string)
+ itemID := responsesItemID(item)
s.currentItem = item
+ s.currentItemID = itemID
+ s.setEventOutputIndex(event, itemID)
s.currentItemType = itemType
s.currentMessagePartType = ""
s.hasReasoningSummaryPart = false
@@ -780,145 +1008,368 @@ func (s *responsesStreamState) apply(stream *ai.AssistantMessageEventStream, out
case "reasoning":
s.blocks = append(s.blocks, ai.ContentBlock{Type: "thinking"})
s.currentIndex = len(s.blocks) - 1
+ s.setItemIndex(itemID, itemType, s.currentIndex)
output.Content = s.blocks
push(ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: s.currentIndex, Partial: output})
case "message":
s.blocks = append(s.blocks, ai.ContentBlock{Type: "text"})
s.currentIndex = len(s.blocks) - 1
+ s.setItemIndex(itemID, itemType, s.currentIndex)
output.Content = s.blocks
push(ai.AssistantMessageEvent{Type: "text_start", ContentIndex: s.currentIndex, Partial: output})
case "function_call":
id := fmt.Sprintf("%v|%v", item["call_id"], item["id"])
- arguments := fmt.Sprint(item["arguments"])
+ arguments := ""
+ if rawArguments, ok := item["arguments"]; ok && rawArguments != nil {
+ if text, ok := rawArguments.(string); ok {
+ arguments = text
+ } else {
+ arguments = mustJSON(rawArguments)
+ }
+ }
s.blocks = append(s.blocks, ai.ContentBlock{Type: "toolCall", ID: id, Name: fmt.Sprint(item["name"]), Arguments: parseJSONMap(arguments)})
s.currentIndex = len(s.blocks) - 1
+ s.setItemIndex(itemID, itemType, s.currentIndex)
s.toolArgsByIndex[s.currentIndex] = arguments
+ if itemID != "" {
+ s.toolArgsByItemID[itemID] = arguments
+ }
output.Content = s.blocks
toolCall := ai.ToolCall{Type: "toolCall", ID: id, Name: s.blocks[s.currentIndex].Name, Arguments: s.blocks[s.currentIndex].Arguments}
push(ai.AssistantMessageEvent{Type: "toolcall_start", ContentIndex: s.currentIndex, ToolCall: &toolCall, Partial: output})
+ if arguments != "" {
+ push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: s.currentIndex, Delta: arguments, ToolCall: &toolCall, Partial: output})
+ }
case "image_generation_call":
s.blocks = append(s.blocks, imageBlockFromGenerationItem(item, ai.ContentBlock{}))
s.currentIndex = len(s.blocks) - 1
+ s.setItemIndex(itemID, itemType, s.currentIndex)
output.Content = s.blocks
+ case "web_search_call", "openrouter:web_search", "openrouter:web_fetch":
+ toolCall, ok := responsesNativeToolCall(item, len(s.nativeToolsByItemID), model.Provider)
+ if !ok {
+ return
+ }
+ toolKey := responsesNativeToolKey(item, toolCall)
+ s.nativeToolsByItemID[toolKey] = toolCall
+ if itemID == "" {
+ s.setEventOutputIndex(event, toolKey)
+ s.currentItemID = toolKey
+ }
+ s.currentIndex = -1
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "toolcall_start", ToolCall: &toolCall, Partial: output})
+ if len(toolCall.Arguments) > 0 {
+ push(ai.AssistantMessageEvent{Type: "toolcall_delta", Delta: mustJSON(toolCall.Arguments), ToolCall: &toolCall, Partial: output})
+ }
}
case "response.reasoning_summary_part.added":
- if s.currentItemType == "reasoning" {
- s.hasReasoningSummaryPart = true
+ itemID := s.eventItemID(event)
+ if s.eventItemType(event) == "reasoning" {
+ if itemID != "" {
+ s.reasoningSummaryByID[itemID] = true
+ }
+ if itemID == "" || itemID == s.currentItemID {
+ s.hasReasoningSummaryPart = true
+ }
}
case "response.reasoning_summary_text.delta":
- if s.hasReasoningSummaryPart && s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "thinking" {
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ hasSummaryPart := s.hasReasoningSummaryPart
+ if itemID != "" {
+ hasSummaryPart = s.reasoningSummaryByID[itemID]
+ }
+ if hasSummaryPart && index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "thinking" {
delta, _ := event["delta"].(string)
- s.blocks[s.currentIndex].Thinking += delta
+ s.blocks[index].Thinking += delta
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: s.currentIndex, Delta: delta, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: delta, Partial: output})
}
case "response.reasoning_text.delta":
- if s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "thinking" {
+ index := s.eventContentIndex(event)
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "thinking" {
delta, _ := event["delta"].(string)
- s.blocks[s.currentIndex].Thinking += delta
+ s.blocks[index].Thinking += delta
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: s.currentIndex, Delta: delta, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ case "response.reasoning_text.done":
+ index := s.eventContentIndex(event)
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "thinking" {
+ text, _ := event["text"].(string)
+ if delta := appendMissingPrefix(s.blocks[index].Thinking, text); delta != "" {
+ s.blocks[index].Thinking += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
}
case "response.reasoning_summary_part.done":
- if s.hasReasoningSummaryPart && s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "thinking" {
- s.blocks[s.currentIndex].Thinking += "\n\n"
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ hasSummaryPart := s.hasReasoningSummaryPart
+ if itemID != "" {
+ hasSummaryPart = s.reasoningSummaryByID[itemID]
+ }
+ if hasSummaryPart && index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "thinking" {
+ if part, ok := event["part"].(map[string]any); ok {
+ if delta := appendMissingPrefix(s.blocks[index].Thinking, responsesPartText(part, "summary_text")); delta != "" {
+ s.blocks[index].Thinking += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ }
+ if s.blocks[index].Thinking == "" || strings.HasSuffix(s.blocks[index].Thinking, "\n\n") {
+ return
+ }
+ s.blocks[index].Thinking += "\n\n"
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: s.currentIndex, Delta: "\n\n", Partial: output})
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: "\n\n", Partial: output})
+ }
+ case "response.reasoning_summary_text.done":
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ hasSummaryPart := s.hasReasoningSummaryPart
+ if itemID != "" {
+ hasSummaryPart = s.reasoningSummaryByID[itemID]
+ }
+ if hasSummaryPart && index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "thinking" {
+ text, _ := event["text"].(string)
+ if delta := appendMissingPrefix(s.blocks[index].Thinking, text); delta != "" {
+ s.blocks[index].Thinking += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
}
case "response.content_part.added":
- if s.currentItemType == "message" {
+ itemID := s.eventItemID(event)
+ if s.eventItemType(event) == "message" {
if part, ok := event["part"].(map[string]any); ok {
partType, _ := part["type"].(string)
if partType == "output_text" || partType == "refusal" {
- s.currentMessagePartType = partType
+ if itemID != "" {
+ s.messagePartTypeByID[itemID] = partType
+ }
+ if itemID == "" || itemID == s.currentItemID {
+ s.currentMessagePartType = partType
+ }
}
}
}
case "response.output_text.delta":
- if s.currentItemType == "message" && s.currentMessagePartType == "" {
- s.currentMessagePartType = "output_text"
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ partType := s.currentMessagePartType
+ if itemID != "" {
+ partType = s.messagePartTypeByID[itemID]
}
- if s.currentMessagePartType == "output_text" && s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "text" {
+ if s.eventItemType(event) == "message" && partType == "" {
+ partType = "output_text"
+ if itemID != "" {
+ s.messagePartTypeByID[itemID] = partType
+ }
+ if itemID == "" || itemID == s.currentItemID {
+ s.currentMessagePartType = partType
+ }
+ }
+ if partType == "output_text" && index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "text" {
delta, _ := event["delta"].(string)
- s.blocks[s.currentIndex].Text += delta
+ s.blocks[index].Text += delta
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: s.currentIndex, Delta: delta, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ case "response.output_text.done":
+ index := s.eventContentIndex(event)
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "text" {
+ text, _ := event["text"].(string)
+ if delta := appendMissingPrefix(s.blocks[index].Text, text); delta != "" {
+ s.blocks[index].Text += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
}
case "response.refusal.delta":
- if s.currentItemType == "message" && s.currentMessagePartType == "" {
- s.currentMessagePartType = "refusal"
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ partType := s.currentMessagePartType
+ if itemID != "" {
+ partType = s.messagePartTypeByID[itemID]
}
- if s.currentMessagePartType == "refusal" && s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "text" {
+ if s.eventItemType(event) == "message" && partType == "" {
+ partType = "refusal"
+ if itemID != "" {
+ s.messagePartTypeByID[itemID] = partType
+ }
+ if itemID == "" || itemID == s.currentItemID {
+ s.currentMessagePartType = partType
+ }
+ }
+ if partType == "refusal" && index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "text" {
delta, _ := event["delta"].(string)
- s.blocks[s.currentIndex].Text += delta
+ s.blocks[index].Text += delta
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: s.currentIndex, Delta: delta, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ case "response.refusal.done":
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ if itemID != "" {
+ s.messagePartTypeByID[itemID] = "refusal"
+ }
+ if itemID == "" || itemID == s.currentItemID {
+ s.currentMessagePartType = "refusal"
+ }
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "text" {
+ refusal, _ := event["refusal"].(string)
+ if delta := appendMissingPrefix(s.blocks[index].Text, refusal); delta != "" {
+ s.blocks[index].Text += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ }
+ case "response.content_part.done":
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ part, _ := event["part"].(map[string]any)
+ partType, _ := part["type"].(string)
+ switch partType {
+ case "output_text", "refusal":
+ if itemID != "" {
+ s.messagePartTypeByID[itemID] = partType
+ }
+ if itemID == "" || itemID == s.currentItemID {
+ s.currentMessagePartType = partType
+ }
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "text" {
+ if delta := appendMissingPrefix(s.blocks[index].Text, responsesPartText(part, partType)); delta != "" {
+ s.blocks[index].Text += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ }
+ case "reasoning_text":
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "thinking" {
+ if delta := appendMissingPrefix(s.blocks[index].Thinking, responsesPartText(part, partType)); delta != "" {
+ s.blocks[index].Thinking += delta
+ output.Content = s.blocks
+ push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: index, Delta: delta, Partial: output})
+ }
+ }
+ }
+ case "response.output_text.annotation.added":
+ contentIndex := s.eventContentIndex(event)
+ if contentIndex < 0 {
+ contentIndex = intFromAny(event["content_index"])
+ }
+ if annotation, ok := event["annotation"].(map[string]any); ok {
+ output.Citations = append(output.Citations, providerCitationsFromAny(annotation, model.Provider, contentIndex)...)
+ push(ai.AssistantMessageEvent{Type: "source", Partial: output})
}
case "response.function_call_arguments.delta":
- if s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "toolCall" {
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "toolCall" {
delta, _ := event["delta"].(string)
- s.toolArgsByIndex[s.currentIndex] += delta
- s.blocks[s.currentIndex].Arguments = parseJSONMap(s.toolArgsByIndex[s.currentIndex])
+ if itemID != "" {
+ s.toolArgsByItemID[itemID] += delta
+ s.blocks[index].Arguments = parseJSONMap(s.toolArgsByItemID[itemID])
+ } else {
+ s.toolArgsByIndex[index] += delta
+ s.blocks[index].Arguments = parseJSONMap(s.toolArgsByIndex[index])
+ }
output.Content = s.blocks
- toolCall := ai.ToolCall{Type: "toolCall", ID: s.blocks[s.currentIndex].ID, Name: s.blocks[s.currentIndex].Name, Arguments: s.blocks[s.currentIndex].Arguments}
- push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: s.currentIndex, Delta: delta, ToolCall: &toolCall, Partial: output})
+ toolCall := ai.ToolCall{Type: "toolCall", ID: s.blocks[index].ID, Name: s.blocks[index].Name, Arguments: s.blocks[index].Arguments}
+ push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: index, Delta: delta, ToolCall: &toolCall, Partial: output})
}
case "response.function_call_arguments.done":
- if s.currentIndex >= 0 && s.blocks[s.currentIndex].Type == "toolCall" {
+ itemID := s.eventItemID(event)
+ index := s.eventContentIndex(event)
+ if index >= 0 && index < len(s.blocks) && s.blocks[index].Type == "toolCall" {
args, _ := event["arguments"].(string)
- previous := s.toolArgsByIndex[s.currentIndex]
- s.toolArgsByIndex[s.currentIndex] = args
- s.blocks[s.currentIndex].Arguments = parseJSONMap(args)
+ previous := s.toolArgsByIndex[index]
+ if itemID != "" {
+ previous = s.toolArgsByItemID[itemID]
+ s.toolArgsByItemID[itemID] = args
+ }
+ s.toolArgsByIndex[index] = args
+ s.blocks[index].Arguments = parseJSONMap(args)
output.Content = s.blocks
if strings.HasPrefix(args, previous) && len(args) > len(previous) {
- toolCall := ai.ToolCall{Type: "toolCall", ID: s.blocks[s.currentIndex].ID, Name: s.blocks[s.currentIndex].Name, Arguments: s.blocks[s.currentIndex].Arguments}
- push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: s.currentIndex, Delta: args[len(previous):], ToolCall: &toolCall, Partial: output})
+ toolCall := ai.ToolCall{Type: "toolCall", ID: s.blocks[index].ID, Name: s.blocks[index].Name, Arguments: s.blocks[index].Arguments}
+ push(ai.AssistantMessageEvent{Type: "toolcall_delta", ContentIndex: index, Delta: args[len(previous):], ToolCall: &toolCall, Partial: output})
}
}
case "response.output_item.done":
item, _ := event["item"].(map[string]any)
itemType, _ := item["type"].(string)
- if s.currentIndex < 0 && itemType != "image_generation_call" {
+ itemID := responsesItemID(item)
+ if itemID == "" {
+ itemID = s.eventItemID(event)
+ }
+ if _, _, ok := responsesNativeToolInfo(item, model.Provider); ok {
+ toolCall := s.nativeToolsByItemID[itemID]
+ if toolCall.ID == "" {
+ toolCall, _ = responsesNativeToolCall(item, len(s.nativeToolsByItemID), model.Provider)
+ } else if args := responsesNativeToolArguments(item, toolCall.Name); len(args) > 0 {
+ toolCall.Arguments = args
+ }
+ output.Content = s.blocks
+ if citations := providerCitationsFromAny(item, model.Provider, max(0, s.currentIndex)); len(citations) > 0 {
+ output.Citations = append(output.Citations, citations...)
+ push(ai.AssistantMessageEvent{Type: "source", Partial: output})
+ }
+ push(ai.AssistantMessageEvent{Type: "toolresult", ToolCall: &toolCall, CustomValue: responsesNativeToolResult(item, model.Provider), Partial: output})
+ s.currentIndex = -1
+ return
+ }
+ index := s.currentIndex
+ if itemID != "" {
+ if storedIndex, ok := s.itemIndexByID[itemID]; ok {
+ index = storedIndex
+ }
+ }
+ if index < 0 && itemType != "image_generation_call" {
return
}
switch itemType {
case "reasoning":
- s.blocks[s.currentIndex].Thinking = reasoningTextFromItem(item, s.blocks[s.currentIndex].Thinking)
- s.blocks[s.currentIndex].ThinkingSignature = mustJSON(item)
+ s.blocks[index].Thinking = reasoningTextFromItem(item, s.blocks[index].Thinking)
+ s.blocks[index].ThinkingSignature = mustJSON(item)
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "thinking_end", ContentIndex: s.currentIndex, Content: s.blocks[s.currentIndex].Thinking, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "thinking_end", ContentIndex: index, Content: s.blocks[index].Thinking, Partial: output})
s.currentIndex = -1
case "message":
if text := messageTextFromItem(item); text != "" {
- s.blocks[s.currentIndex].Text = text
+ s.blocks[index].Text = text
}
+ output.Citations = append(output.Citations, providerCitationsFromAny(item, model.Provider, index)...)
if id, ok := item["id"].(string); ok && id != "" {
payload := map[string]any{"v": 1, "id": id}
if phase, ok := item["phase"].(string); ok && phase != "" {
payload["phase"] = phase
}
- s.blocks[s.currentIndex].TextSignature = mustJSON(payload)
+ s.blocks[index].TextSignature = mustJSON(payload)
}
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "text_end", ContentIndex: s.currentIndex, Content: s.blocks[s.currentIndex].Text, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "text_end", ContentIndex: index, Content: s.blocks[index].Text, Partial: output})
s.currentIndex = -1
case "function_call":
- if s.blocks[s.currentIndex].Name == "" {
- s.blocks[s.currentIndex].Name = fmt.Sprint(item["name"])
+ if s.blocks[index].Name == "" {
+ s.blocks[index].Name = fmt.Sprint(item["name"])
}
if args, ok := item["arguments"].(string); ok && args != "" {
- s.blocks[s.currentIndex].Arguments = parseJSONMap(args)
+ s.blocks[index].Arguments = parseJSONMap(args)
}
- toolCall := ai.ToolCall{Type: "toolCall", ID: s.blocks[s.currentIndex].ID, Name: s.blocks[s.currentIndex].Name, Arguments: s.blocks[s.currentIndex].Arguments}
+ toolCall := ai.ToolCall{Type: "toolCall", ID: s.blocks[index].ID, Name: s.blocks[index].Name, Arguments: s.blocks[index].Arguments}
output.Content = s.blocks
- push(ai.AssistantMessageEvent{Type: "toolcall_end", ContentIndex: s.currentIndex, ToolCall: &toolCall, Partial: output})
+ push(ai.AssistantMessageEvent{Type: "toolcall_end", ContentIndex: index, ToolCall: &toolCall, Partial: output})
s.currentIndex = -1
case "image_generation_call":
- if s.currentIndex < 0 || s.currentIndex >= len(s.blocks) || s.blocks[s.currentIndex].Type != "image" {
+ if index < 0 || index >= len(s.blocks) || s.blocks[index].Type != "image" {
s.blocks = append(s.blocks, imageBlockFromGenerationItem(item, ai.ContentBlock{}))
s.currentIndex = len(s.blocks) - 1
} else {
- s.blocks[s.currentIndex] = imageBlockFromGenerationItem(item, s.blocks[s.currentIndex])
+ s.blocks[index] = imageBlockFromGenerationItem(item, s.blocks[index])
}
output.Content = s.blocks
s.currentIndex = -1
diff --git a/pkg/ai/providers/openai_stream_test.go b/pkg/ai/providers/openai_stream_test.go
index 63445246..9d0eaa07 100644
--- a/pkg/ai/providers/openai_stream_test.go
+++ b/pkg/ai/providers/openai_stream_test.go
@@ -167,6 +167,385 @@ func TestResponsesStreamStateFinalizesReasoningTextToolAndUsage(t *testing.T) {
}
}
+func TestResponsesStreamStateBackfillsTextAndReasoningDoneEvents(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{"type": "reasoning", "id": "rs_1"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.reasoning_text.done",
+ "item_id": "rs_1",
+ "text": "full reasoning",
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{"type": "message", "id": "msg_1"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_text.done",
+ "item_id": "msg_1",
+ "text": "full answer",
+ })
+
+ if state.blocks[0].Thinking != "full reasoning" || state.blocks[1].Text != "full answer" {
+ t.Fatalf("expected done events to backfill content, got %#v", state.blocks)
+ }
+ events := drainAssistantEvents(stream)
+ var thinkingDelta, textDelta bool
+ for _, event := range events {
+ if event.Type == "thinking_delta" && event.Delta == "full reasoning" {
+ thinkingDelta = true
+ }
+ if event.Type == "text_delta" && event.Delta == "full answer" {
+ textDelta = true
+ }
+ }
+ if !thinkingDelta || !textDelta {
+ t.Fatalf("expected done events to emit missing deltas, got %#v", events)
+ }
+}
+
+func TestResponsesStreamStateBackfillsPartDoneEvents(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "output_index": 0,
+ "item": map[string]any{"type": "reasoning", "id": "rs_1"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.content_part.done",
+ "output_index": 0,
+ "part": map[string]any{"type": "reasoning_text", "text": "part reasoning"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "output_index": 1,
+ "item": map[string]any{"type": "message", "id": "msg_1"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.content_part.done",
+ "output_index": 1,
+ "part": map[string]any{"type": "output_text", "text": "part answer"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "output_index": 2,
+ "item": map[string]any{"type": "message", "id": "msg_2"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.refusal.done",
+ "output_index": 2,
+ "refusal": "final refusal",
+ })
+
+ if state.blocks[0].Thinking != "part reasoning" || state.blocks[1].Text != "part answer" || state.blocks[2].Text != "final refusal" {
+ t.Fatalf("expected part done events to backfill content, got %#v", state.blocks)
+ }
+ events := drainAssistantEvents(stream)
+ var thinkingDelta, answerDelta, refusalDelta bool
+ for _, event := range events {
+ if event.Type == "thinking_delta" && event.Delta == "part reasoning" {
+ thinkingDelta = true
+ }
+ if event.Type == "text_delta" && event.Delta == "part answer" {
+ answerDelta = true
+ }
+ if event.Type == "text_delta" && event.Delta == "final refusal" {
+ refusalDelta = true
+ }
+ }
+ if !thinkingDelta || !answerDelta || !refusalDelta {
+ t.Fatalf("expected part done events to emit missing deltas, got %#v", events)
+ }
+}
+
+func TestResponsesStreamStateBackfillsReasoningSummaryPartDone(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{"type": "reasoning", "id": "rs_1"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.reasoning_summary_part.added",
+ "item_id": "rs_1",
+ "part": map[string]any{"type": "summary_text", "text": ""},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.reasoning_summary_part.done",
+ "item_id": "rs_1",
+ "part": map[string]any{"type": "summary_text", "text": "summary only on part done"},
+ })
+
+ if state.blocks[0].Thinking != "summary only on part done\n\n" {
+ t.Fatalf("expected summary part done to backfill summary text, got %#v", state.blocks[0])
+ }
+ events := drainAssistantEvents(stream)
+ var summaryDelta, separatorDelta bool
+ for _, event := range events {
+ if event.Type == "thinking_delta" && event.Delta == "summary only on part done" {
+ summaryDelta = true
+ }
+ if event.Type == "thinking_delta" && event.Delta == "\n\n" {
+ separatorDelta = true
+ }
+ }
+ if !summaryDelta || !separatorDelta {
+ t.Fatalf("expected summary part done to emit content and separator deltas, got %#v", events)
+ }
+}
+
+func TestResponsesStreamStateMapsNativeWebSearchCallToToolActivity(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{
+ "type": "web_search_call",
+ "id": "ws_1",
+ "status": "in_progress",
+ "action": map[string]any{"type": "search", "query": "latest headlines Amsterdam news today"},
+ },
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{
+ "type": "web_search_call",
+ "id": "ws_1",
+ "status": "completed",
+ "action": map[string]any{"type": "search", "query": "latest headlines Amsterdam news today"},
+ },
+ })
+
+ events := drainAssistantEvents(stream)
+ if len(output.Content.([]ai.ContentBlock)) != 0 {
+ t.Fatalf("native web search must not become an executable tool block, got %#v", output.Content)
+ }
+ var started, result bool
+ for _, event := range events {
+ switch event.Type {
+ case "toolcall_start":
+ started = event.ToolCall != nil && event.ToolCall.ID == "ws_1" && event.ToolCall.Name == "web_search"
+ case "toolresult":
+ result = event.ToolCall != nil && event.ToolCall.ID == "ws_1"
+ if output, _ := event.CustomValue.(map[string]any); output["status"] != "success" || output["native"] != true {
+ t.Fatalf("unexpected native search result payload %#v", event.CustomValue)
+ }
+ }
+ }
+ if !started || !result {
+ t.Fatalf("expected native web search start/result events, got %#v", events)
+ }
+}
+
+func TestResponsesStreamStateKeepsReasoningStreamingAcrossNativeToolItems(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{"type": "reasoning", "id": "rs_1"},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.reasoning_summary_part.added",
+ "item_id": "rs_1",
+ "part": map[string]any{"type": "summary_text", "text": ""},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{
+ "type": "web_search_call",
+ "id": "ws_1",
+ "status": "searching",
+ "action": map[string]any{"type": "search", "query": "Amsterdam news"},
+ },
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.reasoning_summary_text.delta",
+ "item_id": "rs_1",
+ "delta": "checking sources",
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{"type": "web_search_call", "id": "ws_1", "status": "completed", "action": map[string]any{"type": "search", "query": "Amsterdam news"}},
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{"type": "reasoning", "id": "rs_1", "summary": []any{map[string]any{"type": "summary_text", "text": "checking sources"}}},
+ })
+
+ if len(state.blocks) != 1 || state.blocks[0].Thinking != "checking sources" {
+ t.Fatalf("expected reasoning summary to survive native tool interleave, got %#v", state.blocks)
+ }
+ events := drainAssistantEvents(stream)
+ var thinkingDelta, thinkingEnd bool
+ for _, event := range events {
+ if event.Type == "thinking_delta" && event.Delta == "checking sources" {
+ thinkingDelta = true
+ }
+ if event.Type == "thinking_end" && event.Content == "checking sources" {
+ thinkingEnd = true
+ }
+ }
+ if !thinkingDelta || !thinkingEnd {
+ t.Fatalf("expected streamed reasoning delta and end, got %#v", events)
+ }
+}
+
+func TestResponsesStreamStateMapsOpenRouterWebSearchToToolActivity(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ model.Provider = ai.ProviderOpenRouter
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{
+ "type": "openrouter:web_search",
+ "id": "or_search_1",
+ "status": "in_progress",
+ },
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{
+ "type": "openrouter:web_search",
+ "id": "or_search_1",
+ "status": "completed",
+ "action": map[string]any{"type": "search", "query": "current Amsterdam headline"},
+ },
+ })
+
+ events := drainAssistantEvents(stream)
+ if len(output.Content.([]ai.ContentBlock)) != 0 {
+ t.Fatalf("OpenRouter native search must not become an executable tool block, got %#v", output.Content)
+ }
+ var started, result bool
+ for _, event := range events {
+ switch event.Type {
+ case "toolcall_start":
+ started = event.ToolCall != nil && event.ToolCall.ID == "or_search_1" && event.ToolCall.Name == "web_search"
+ case "toolresult":
+ result = event.ToolCall != nil && event.ToolCall.ID == "or_search_1" && event.ToolCall.Arguments["query"] == "current Amsterdam headline"
+ output, _ := event.CustomValue.(map[string]any)
+ if output["provider"] != string(ai.ProviderOpenRouter) || output["status"] != "success" || output["query"] != "current Amsterdam headline" {
+ t.Fatalf("unexpected OpenRouter search result payload %#v", event.CustomValue)
+ }
+ }
+ }
+ if !started || !result {
+ t.Fatalf("expected OpenRouter web search start/result events, got %#v", events)
+ }
+}
+
+func TestResponsesStreamStateMapsOpenRouterWebFetchToToolActivity(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ model.Provider = ai.ProviderOpenRouter
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.added",
+ "item": map[string]any{
+ "type": "openrouter:web_fetch",
+ "id": "or_fetch_1",
+ "status": "in_progress",
+ },
+ })
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{
+ "type": "openrouter:web_fetch",
+ "id": "or_fetch_1",
+ "status": "completed",
+ "url": "https://example.com/article",
+ "title": "Fetched Article",
+ "httpStatus": float64(200),
+ "content": "Fetched Article body that should not be copied into the tool output.",
+ },
+ })
+
+ events := drainAssistantEvents(stream)
+ var sawSource, sawResult bool
+ for _, event := range events {
+ switch event.Type {
+ case "source":
+ sawSource = len(output.Citations) == 1 && output.Citations[0].URL == "https://example.com/article"
+ case "toolresult":
+ sawResult = event.ToolCall != nil && event.ToolCall.Name == "fetch"
+ output, _ := event.CustomValue.(map[string]any)
+ if output["provider"] != string(ai.ProviderOpenRouter) || output["url"] != "https://example.com/article" || output["title"] != "Fetched Article" || output["content"] != nil {
+ t.Fatalf("unexpected OpenRouter fetch result payload %#v", event.CustomValue)
+ }
+ }
+ }
+ if !sawSource || !sawResult {
+ t.Fatalf("expected OpenRouter fetch source/result events, got %#v citations=%#v", events, output.Citations)
+ }
+}
+
+func TestResponsesStreamStateMapsOpenRouterWebFetchFailure(t *testing.T) {
+ stream := ai.NewAssistantMessageEventStream()
+ model := testStreamModel()
+ model.API = ai.ApiOpenAIResponses
+ model.Provider = ai.ProviderOpenRouter
+ output := newAssistant(model)
+ state := newResponsesStreamState()
+
+ state.apply(stream, &output, model, OpenAIResponsesOptions{}, map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{
+ "type": "openrouter:web_fetch",
+ "id": "or_fetch_fail",
+ "status": "incomplete",
+ "url": "https://nonexistent.invalid/does-not-exist",
+ "error": "Exa returned no content for this URL.",
+ },
+ })
+
+ events := drainAssistantEvents(stream)
+ var result bool
+ for _, event := range events {
+ if event.Type != "toolresult" {
+ continue
+ }
+ result = event.ToolCall != nil && event.ToolCall.Name == "fetch"
+ output, _ := event.CustomValue.(map[string]any)
+ if output["status"] != "failed" || output["reason"] != "Exa returned no content for this URL." {
+ t.Fatalf("unexpected OpenRouter fetch failure payload %#v", event.CustomValue)
+ }
+ }
+ if !result {
+ t.Fatalf("expected OpenRouter fetch failure result, got %#v", events)
+ }
+}
+
func TestResponsesStreamStateStreamsTextDeltasWithoutContentPartPrelude(t *testing.T) {
stream := ai.NewAssistantMessageEventStream()
model := testStreamModel()
@@ -219,6 +598,18 @@ func TestResponsesStreamStateStreamsTextDeltasWithoutContentPartPrelude(t *testi
}
}
+func drainAssistantEvents(stream *ai.AssistantMessageEventStream) []ai.AssistantMessageEvent {
+ events := []ai.AssistantMessageEvent{}
+ for {
+ select {
+ case event := <-stream.Events():
+ events = append(events, event)
+ default:
+ return events
+ }
+ }
+}
+
func TestResponsesStreamStateFinalizesImageGenerationCall(t *testing.T) {
stream := ai.NewAssistantMessageEventStream()
model := testStreamModel()
@@ -304,6 +695,16 @@ func TestResponsesStreamStateSeedsFunctionCallArgumentsFromAddedItem(t *testing.
if len(state.blocks) != 1 || state.blocks[0].Arguments["path"] != "x" {
t.Fatalf("expected initial item arguments and delta to combine, got %#v", state.blocks)
}
+ events := drainAssistantEvents(stream)
+ var sawInitialArgs bool
+ for _, event := range events {
+ if event.Type == "toolcall_delta" && event.Delta == `{"path"` {
+ sawInitialArgs = true
+ }
+ }
+ if !sawInitialArgs {
+ t.Fatalf("expected initial function-call arguments to stream as args, got %#v", events)
+ }
}
func TestFinishResponsesStreamEmitsErrorForFailedResponse(t *testing.T) {
diff --git a/pkg/ai/types.go b/pkg/ai/types.go
index a5803360..0739b88b 100644
--- a/pkg/ai/types.go
+++ b/pkg/ai/types.go
@@ -8,6 +8,7 @@ type Provider string
type ImagesProvider string
type ThinkingLevel string
type ModelThinkingLevel string
+type ModelReasoningMode string
type CacheRetention string
type Transport string
type StopReason string
@@ -69,6 +70,8 @@ const (
ModelThinkingLevelOff ModelThinkingLevel = "off"
+ ModelReasoningModeAdaptive ModelReasoningMode = "adaptive"
+
CacheRetentionNone CacheRetention = "none"
CacheRetentionShort CacheRetention = "short"
CacheRetentionLong CacheRetention = "long"
@@ -139,6 +142,7 @@ type Model struct {
Reasoning bool `json:"reasoning"`
ThinkingLevelMap map[ModelThinkingLevel]*string `json:"thinkingLevelMap,omitempty"`
DefaultThinkingLevel ModelThinkingLevel `json:"defaultThinkingLevel,omitempty"`
+ ReasoningMode ModelReasoningMode `json:"reasoningMode,omitempty"`
Input []string `json:"input"`
Output []string `json:"output,omitempty"`
Cost ModelCost `json:"cost"`
@@ -248,6 +252,24 @@ type Message struct {
FromID string `json:"fromId,omitempty"`
TokensBefore int `json:"tokensBefore,omitempty"`
Truncated bool `json:"truncated,omitempty"`
+ Citations []Citation `json:"citations,omitempty"`
+}
+
+type Citation struct {
+ Type string `json:"type,omitempty"`
+ URL string `json:"url,omitempty"`
+ Title string `json:"title,omitempty"`
+ Description string `json:"description,omitempty"`
+ SiteName string `json:"siteName,omitempty"`
+ FaviconURL string `json:"faviconUrl,omitempty"`
+ ImageURL string `json:"imageUrl,omitempty"`
+ PublishedAt string `json:"publishedAt,omitempty"`
+ StartIndex *int `json:"startIndex,omitempty"`
+ EndIndex *int `json:"endIndex,omitempty"`
+ ContentIndex *int `json:"contentIndex,omitempty"`
+ Text string `json:"text,omitempty"`
+ Provider string `json:"provider,omitempty"`
+ RawType string `json:"rawType,omitempty"`
}
type Tool struct {
diff --git a/pkg/ai/utils/http_logging.go b/pkg/ai/utils/http_logging.go
index 191fb6c6..0be8dac4 100644
--- a/pkg/ai/utils/http_logging.go
+++ b/pkg/ai/utils/http_logging.go
@@ -65,7 +65,7 @@ func aiServicesRequestLogger(req *http.Request) zerolog.Logger {
if contentType := req.Header.Get("Content-Type"); contentType != "" {
logCtx = logCtx.Str("content_type", contentType)
}
- if requestID := requestHeader(req.Header, "x-client-request-id", "x-request-id", "session_id"); requestID != "" {
+ if requestID := requestHeader(req.Header, "x-client-request-id", "x-request-id", "session-id", "session_id"); requestID != "" {
logCtx = logCtx.Str("request_id", requestID)
}
return logCtx.Logger()
diff --git a/pkg/ai/utils/http_logging_test.go b/pkg/ai/utils/http_logging_test.go
index 56b872e8..c84e1aa7 100644
--- a/pkg/ai/utils/http_logging_test.go
+++ b/pkg/ai/utils/http_logging_test.go
@@ -32,7 +32,7 @@ func TestAIServicesLoggingTransportLogsMetadataWithoutBodies(t *testing.T) {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer secret-token")
- req.Header.Set("X-Client-Request-ID", "session_123")
+ req.Header.Set("Session-ID", "session_123")
resp, err := WithAIServicesLogging(&http.Client{}).Do(req)
if err != nil {
@@ -47,6 +47,9 @@ func TestAIServicesLoggingTransportLogsMetadataWithoutBodies(t *testing.T) {
if !strings.Contains(output, `"action":"ai_services_http"`) || !strings.Contains(output, `"request_number":`) {
t.Fatalf("missing request context:\n%s", output)
}
+ if !strings.Contains(output, `"request_id":"session_123"`) {
+ t.Fatalf("missing session-id request id:\n%s", output)
+ }
if !strings.Contains(output, `"status_code":401`) || !strings.Contains(output, `"level":"error"`) || !strings.Contains(output, `"duration":`) {
t.Fatalf("missing error response metadata:\n%s", output)
}
diff --git a/pkg/ai/utils/overflow.go b/pkg/ai/utils/overflow.go
index 40aa7a53..99df8485 100644
--- a/pkg/ai/utils/overflow.go
+++ b/pkg/ai/utils/overflow.go
@@ -29,6 +29,7 @@ var overflowPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)too many tokens`),
regexp.MustCompile(`(?i)token limit exceeded`),
regexp.MustCompile(`(?i)^4(?:00|13)\s*(?:status code)?\s*\(no body\)`),
+ regexp.MustCompile(`(?i)\b4(?:00|13)\s+[a-z ]+\(no body\)`),
}
var nonOverflowPatterns = []*regexp.Regexp{
diff --git a/pkg/ai/utils/overflow_test.go b/pkg/ai/utils/overflow_test.go
index c6b676ff..929527bf 100644
--- a/pkg/ai/utils/overflow_test.go
+++ b/pkg/ai/utils/overflow_test.go
@@ -15,6 +15,10 @@ func TestIsContextOverflowFromErrorMessage(t *testing.T) {
if IsContextOverflow(nonOverflow) {
t.Fatal("expected rate limit to be excluded")
}
+ anthropicNoBody := ai.Message{StopReason: ai.StopReasonError, ErrorMessage: "Anthropic API error (400): 400 Bad Request (no body)"}
+ if !IsContextOverflow(anthropicNoBody) {
+ t.Fatal("expected provider-prefixed no-body 400 to be detected")
+ }
}
func TestIsContextOverflowFromUsage(t *testing.T) {
diff --git a/pkg/aiid/ids_test.go b/pkg/aiid/ids_test.go
index eb8bc649..8a63ac68 100644
--- a/pkg/aiid/ids_test.go
+++ b/pkg/aiid/ids_test.go
@@ -66,6 +66,7 @@ func TestMetadataJSONRoundTrip(t *testing.T) {
Providers: map[string]ProviderConfig{
provider.ID: provider,
},
+ LastKnownTimezone: "Europe/Amsterdam",
}
raw, err := json.Marshal(meta)
if err != nil {
@@ -82,6 +83,9 @@ func TestMetadataJSONRoundTrip(t *testing.T) {
if decodedProvider.DefaultModel != "gpt-5" {
t.Fatalf("metadata did not round trip: %#v", decoded)
}
+ if decoded.LastKnownTimezone != "Europe/Amsterdam" {
+ t.Fatalf("timezone metadata did not round trip: %#v", decoded)
+ }
}
func TestMediaIDForEncodesMetadata(t *testing.T) {
diff --git a/pkg/aiid/metadata.go b/pkg/aiid/metadata.go
index 63ce023f..36c38b9e 100644
--- a/pkg/aiid/metadata.go
+++ b/pkg/aiid/metadata.go
@@ -19,8 +19,9 @@ type ProviderConfig struct {
}
type UserLoginMetadata struct {
- Providers map[string]ProviderConfig `json:"providers,omitempty"`
- Approvals map[string]ApprovalDecision `json:"approvals,omitempty"`
+ Providers map[string]ProviderConfig `json:"providers,omitempty"`
+ Approvals map[string]ApprovalDecision `json:"approvals,omitempty"`
+ LastKnownTimezone string `json:"last_known_timezone,omitempty"`
}
type ApprovalDecision struct {
diff --git a/pkg/chattools/chattools.go b/pkg/chattools/chattools.go
index b7b8675b..95fd0b49 100644
--- a/pkg/chattools/chattools.go
+++ b/pkg/chattools/chattools.go
@@ -9,7 +9,9 @@ func Tools(info SessionInfo, fetch FetchOptions, search SearchOptions) []agent.A
func ToolsWithOptions(info SessionInfo, fetch FetchOptions, search SearchOptions, sessionOptions SessionOptions) []agent.AgentTool[any] {
tools := []agent.AgentTool[any]{
GetSessionToolWithOptions(info, sessionOptions),
- FetchTool(fetch),
+ }
+ if !fetch.Disabled {
+ tools = append(tools, FetchTool(fetch))
}
if search.Enabled {
tools = append(tools, WebSearchTool(search))
diff --git a/pkg/chattools/chattools_test.go b/pkg/chattools/chattools_test.go
index 09b55615..077ef5e7 100644
--- a/pkg/chattools/chattools_test.go
+++ b/pkg/chattools/chattools_test.go
@@ -28,13 +28,16 @@ func TestGetSessionSchemaUsesArrayRequired(t *testing.T) {
func TestGetSessionReturnsFreshMetadata(t *testing.T) {
tool := GetSessionTool(SessionInfo{
- ChatID: "session-1",
- ChatTitle: "Markdown Chaos Test",
- ChatFirstMessageAt: "2026-05-31T22:00:00Z",
- SelectedModel: "gpt-5",
- SelectedReasoning: "low",
- DisabledTools: []string{"web_search"},
- LastKnownTimestamp: "2026-05-31T22:34:00Z",
+ ChatID: "session-1",
+ ChatTitle: "Markdown Chaos Test",
+ ChatFirstMessageAt: "2026-05-31T22:00:00Z",
+ SelectedModel: "gpt-5",
+ SelectedReasoning: "low",
+ DisabledTools: []string{"web_search"},
+ SearchMode: "off",
+ FetchMode: "beeper",
+ LastMessageTimestamp: "2026-05-31T22:34:00Z",
+ LastKnownTimezone: "Europe/Amsterdam",
})
result, err := tool.Execute(context.Background(), "call", map[string]any{}, nil)
if err != nil {
@@ -44,10 +47,10 @@ func TestGetSessionReturnsFreshMetadata(t *testing.T) {
if err := json.Unmarshal([]byte(result.Content[0].Text), &info); err != nil {
t.Fatal(err)
}
- if info.CurrentTimestamp == "" || info.LastKnownTimestamp != "2026-05-31T22:34:00Z" || info.ChatID != "session-1" || info.ChatTitle != "Markdown Chaos Test" || info.SelectedModel != "gpt-5" || len(info.DisabledTools) != 1 || info.DisabledTools[0] != "web_search" {
+ if info.CurrentTimestamp == "" || info.LastMessageTimestamp != "2026-05-31T22:34:00Z" || info.LastKnownTimezone != "Europe/Amsterdam" || info.ChatID != "session-1" || info.ChatTitle != "Markdown Chaos Test" || info.SelectedModel != "gpt-5" || len(info.DisabledTools) != 1 || info.DisabledTools[0] != "web_search" || info.SearchMode != "off" || info.FetchMode != "beeper" {
t.Fatalf("expected fresh session metadata, got %#v", info)
}
- assertSessionKeys(t, result.Content[0].Text, "current_timestamp", "chat_id", "chat_title", "chat_first_message_at", "selected_model", "selected_reasoning", "disabled_tools", "last_known_timestamp")
+ assertSessionKeys(t, result.Content[0].Text, "current_timestamp", "chat_id", "chat_title", "chat_first_message_at", "selected_model", "selected_reasoning", "disabled_tools", "search_mode", "fetch_mode", "last_message_timestamp", "last_known_timezone")
}
func TestGetSessionIncludesProfileOnlyWhenResolverReturnsIt(t *testing.T) {
@@ -70,7 +73,7 @@ func TestGetSessionIncludesProfileOnlyWhenResolverReturnsIt(t *testing.T) {
if info.BeeperAccountEmail != "user@example.com" || info.BeeperUsername != "user" || info.BeeperDisplayName != "User Name" || info.GravatarProfile == nil {
t.Fatalf("missing approved profile: %#v", info)
}
- assertSessionKeys(t, result.Content[0].Text, "current_timestamp", "chat_id", "beeper_username", "beeper_display_name", "beeper_account_email", "gravatar_profile", "last_known_timestamp")
+ assertSessionKeys(t, result.Content[0].Text, "current_timestamp", "chat_id", "beeper_username", "beeper_display_name", "beeper_account_email", "gravatar_profile", "last_message_timestamp")
baseline := GetSessionToolWithOptions(SessionInfo{ChatID: "session-1"}, SessionOptions{
ResolveProfile: func(ctx context.Context, toolCallID string) (*SessionProfile, error) {
@@ -115,6 +118,15 @@ func TestToolsOmitsDisabledSearch(t *testing.T) {
}
}
+func TestToolsOmitsDisabledFetch(t *testing.T) {
+ tools := Tools(SessionInfo{}, FetchOptions{Disabled: true}, SearchOptions{Enabled: true})
+ for _, tool := range tools {
+ if tool.Tool.Name == "fetch" {
+ t.Fatalf("fetch should not be exposed when disabled")
+ }
+ }
+}
+
func TestFetch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -131,17 +143,20 @@ func TestFetch(t *testing.T) {
}
}
-func TestFetchUsesDirectFetchForAssetsWhenExaConfigured(t *testing.T) {
+func TestFetchUsesDirectFetchForAssetsWhenToolEndpointConfigured(t *testing.T) {
exaHit := false
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "exa.test" {
exaHit = true
return testResponse(req, http.StatusOK, "application/json", `{"results":[]}`), nil
}
+ if !strings.Contains(req.Header.Get("Accept"), "text/markdown") {
+ t.Fatalf("direct fetch should prefer readable representations, got Accept=%q", req.Header.Get("Accept"))
+ }
return testResponse(req, http.StatusOK, "text/markdown", "# Title\n\nBody"), nil
})}
- result, err := Fetch(context.Background(), "https://example.com/doc.md", FetchOptions{Timeout: time.Second, ExaEndpoint: "https://exa.test/contents", Client: client, MaxBytes: 1024, MaxChars: 100})
+ result, err := Fetch(context.Background(), "https://example.com/doc.md", FetchOptions{Timeout: time.Second, ToolEndpoint: "https://exa.test/contents", Client: client, MaxBytes: 1024, MaxChars: 100})
if err != nil {
t.Fatal(err)
}
@@ -150,43 +165,68 @@ func TestFetchUsesDirectFetchForAssetsWhenExaConfigured(t *testing.T) {
}
}
-func TestFetchUsesExaContentsForPages(t *testing.T) {
- exa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer key" {
+func TestFetchUsesMarkdownAlternateAfterToolEndpointFails(t *testing.T) {
+ exaHit := false
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host == "exa.test" {
+ exaHit = true
+ return testResponse(req, http.StatusBadGateway, "text/plain", "nope"), nil
+ }
+ switch req.URL.Path {
+ case "/page":
+ resp := testResponse(req, http.StatusOK, "text/html; charset=utf-8", `HTML page`)
+ resp.Header.Set("Link", `; rel="alternate"; type="text/markdown"`)
+ return resp, nil
+ case "/from-header.md":
+ return testResponse(req, http.StatusOK, "text/markdown", "# Header markdown"), nil
+ default:
+ return testResponse(req, http.StatusNotFound, "text/plain", "not found"), nil
+ }
+ })}
+
+ result, err := Fetch(context.Background(), "https://example.com/page", FetchOptions{Timeout: time.Second, ToolEndpoint: "https://exa.test/contents", Client: client, MaxBytes: 1024, MaxChars: 100})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !exaHit || result.FetchMethod != "direct" || result.FinalURL != "https://example.com/from-header.md" || !strings.Contains(result.Markdown, "Header markdown") {
+ t.Fatalf("unexpected alternate fetch result %#v exaHit=%v", result, exaHit)
+ }
+}
+
+func TestFetchUsesToolEndpointForPages(t *testing.T) {
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host != "exa.test" {
+ return testResponse(req, http.StatusOK, "text/html", "PageHTML page"), nil
+ }
+ if req.Method != http.MethodPost || req.Header.Get("Authorization") != "Bearer key" {
t.Fatalf("unexpected request method/header")
}
var payload map[string]any
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
- urls, ok := payload["urls"].([]any)
- if !ok || len(urls) != 1 || urls[0] != "https://example.com/page" {
- t.Fatalf("unexpected contents payload %#v", payload)
+ if payload["url"] != "https://example.com/page" || payload["max_chars"] != float64(100) {
+ t.Fatalf("unexpected fetch payload %#v", payload)
}
- text, ok := payload["text"].(map[string]any)
- if !ok || text["maxCharacters"] != float64(100) || text["verbosity"] != "standard" {
- t.Fatalf("unexpected text payload %#v", payload)
- }
- _, _ = w.Write([]byte(`{"requestId":"req_1","costDollars":{"total":0.001},"statuses":[{"id":"https://example.com/page","status":"success","source":"crawled"}],"results":[{"id":"doc_1","title":"Page","description":"Page description","url":"https://example.com/page","text":"Extracted page text","publishedDate":"2026-01-01","author":"A","favicon":"https://example.com/favicon.ico","highlights":["hit"],"summary":"sum","extras":{"links":["https://example.com/next"]}}]}`))
- }))
- defer exa.Close()
+ return testResponse(req, http.StatusOK, "application/json", `{"request_id":"req_1","title":"Page","description":"Page description","url":"https://example.com/page","final_url":"https://example.com/page","text":"Plain page text","markdown":"# Extracted page text","published_at":"2026-01-01","author":"A","favicon_url":"https://example.com/favicon.ico","metadata":{"links":["https://example.com/next"]}}`), nil
+ })}
- result, err := Fetch(context.Background(), "https://example.com/page", FetchOptions{Timeout: time.Second, ExaEndpoint: exa.URL, APIKey: "key", MaxChars: 100})
+ result, err := Fetch(context.Background(), "https://example.com/page", FetchOptions{Timeout: time.Second, ToolEndpoint: "https://exa.test/contents", APIKey: "key", Client: client, MaxChars: 100})
if err != nil {
t.Fatal(err)
}
- if result.FetchMethod != "exa" || result.RequestID != "req_1" || result.Source != "crawled" || result.ID != "doc_1" || result.Title != "Page" || result.Text != "Extracted page text" {
- t.Fatalf("unexpected Exa fetch result %#v", result)
+ if result.FetchMethod != "web_tool" || result.RequestID != "req_1" || result.Title != "Page" || result.Text != "Plain page text" || result.Markdown != "# Extracted page text" {
+ t.Fatalf("unexpected fetch result %#v", result)
}
- if result.Description != "Page description" || result.Favicon != "https://example.com/favicon.ico" || result.Published != "2026-01-01" || result.Author != "A" || len(result.Highlights) != 1 || result.Summary != "sum" || result.Extras["links"] == nil {
- t.Fatalf("missing Exa fetch metadata %#v", result)
+ if result.Description != "Page description" || result.Favicon != "https://example.com/favicon.ico" || result.FaviconURL != "https://example.com/favicon.ico" || result.Published != "2026-01-01" || result.Author != "A" || result.Extras["links"] == nil {
+ t.Fatalf("missing fetch metadata %#v", result)
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "costDollars") || strings.Contains(string(raw), "fetch_method") {
- t.Fatalf("Exa internal metadata leaked into fetch JSON: %s", string(raw))
+ t.Fatalf("internal metadata leaked into fetch JSON: %s", string(raw))
}
}
@@ -209,7 +249,7 @@ func TestFetchDirectExtractsHTMLSourceMetadata(t *testing.T) {
}
}
-func TestFetchFallsBackToDirectWhenExaFails(t *testing.T) {
+func TestFetchFallsBackToDirectWhenToolEndpointFails(t *testing.T) {
exaHit := false
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "exa.test" {
@@ -219,7 +259,7 @@ func TestFetchFallsBackToDirectWhenExaFails(t *testing.T) {
return testResponse(req, http.StatusOK, "text/html", "FallbackDirect page"), nil
})}
- result, err := Fetch(context.Background(), "https://example.com/page", FetchOptions{Timeout: time.Second, ExaEndpoint: "https://exa.test/contents", Client: client, MaxBytes: 1024, MaxChars: 100})
+ result, err := Fetch(context.Background(), "https://example.com/page", FetchOptions{Timeout: time.Second, ToolEndpoint: "https://exa.test/contents", Client: client, MaxBytes: 1024, MaxChars: 100})
if err != nil {
t.Fatal(err)
}
@@ -228,6 +268,26 @@ func TestFetchFallsBackToDirectWhenExaFails(t *testing.T) {
}
}
+func TestFetchNonSuccessDirectFallsBackToToolEndpoint(t *testing.T) {
+ var directHits, toolHits int
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host == "exa.test" {
+ toolHits++
+ return testResponse(req, http.StatusOK, "application/json", `{"url":"https://example.com/not-found.txt","markdown":"Recovered"}`), nil
+ }
+ directHits++
+ return testResponse(req, http.StatusNotFound, "text/plain", "not found"), nil
+ })}
+
+ result, err := Fetch(context.Background(), "https://example.com/not-found.txt", FetchOptions{Timeout: time.Second, ToolEndpoint: "https://exa.test/contents", Client: client, MaxBytes: 1024, MaxChars: 100})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if directHits != 1 || toolHits != 1 || result.FetchMethod != "web_tool" || result.Text != "Recovered" {
+ t.Fatalf("unexpected fallback result %#v directHits=%d toolHits=%d", result, directHits, toolHits)
+ }
+}
+
func TestFetchRejectsUnsupportedScheme(t *testing.T) {
if _, err := Fetch(context.Background(), "file:///etc/passwd", FetchOptions{}); err == nil {
t.Fatalf("expected unsupported scheme error")
@@ -243,13 +303,10 @@ func TestSearchUsesConfiguredEndpoint(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
- if payload["query"] != "query" || payload["numResults"] != float64(5) {
- t.Fatalf("unexpected search payload %#v", payload)
- }
- if payload["useAutoprompt"] != false {
+ if payload["query"] != "query" || payload["limit"] != float64(5) {
t.Fatalf("unexpected search payload %#v", payload)
}
- _, _ = w.Write([]byte(`{"requestId":"req_1","resolvedSearchType":"auto","costDollars":{"total":0.001},"output":{"content":"synth"},"results":[{"id":"doc_1","title":"One","url":"https://example.com","text":"ok","highlights":["hit"],"highlightScores":[0.5],"summary":"sum","publishedDate":"2026-01-01","siteName":"Example","author":"A","image":"https://example.com/image.png","favicon":"https://example.com/favicon.ico","subpages":[{"id":"sub_1","title":"Sub","url":"https://example.com/sub","text":"sub text","highlights":["sub hit"],"highlightScores":[0.4],"summary":"sub sum","publishedDate":"2026-01-02","author":"Sub A","image":"https://example.com/sub.png","favicon":"https://example.com/sub.ico","extras":{"links":["https://example.com/sub-link"]}}],"entities":[{"type":"company"}],"extras":{"links":["https://example.com/link"]}}]}`))
+ _, _ = w.Write([]byte(`{"request_id":"req_1","search_context_size":"medium","results":[{"id":"doc_1","title":"One","url":"https://example.com","text":"ok","highlights":["hit"],"summary":"sum","published_at":"2026-01-01","site_name":"Example","author":"A","image_url":"https://example.com/image.png","favicon_url":"https://example.com/favicon.ico","metadata":{"links":["https://example.com/link"]}}]}`))
}))
defer server.Close()
@@ -257,56 +314,58 @@ func TestSearchUsesConfiguredEndpoint(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if result.Query != "query" || result.RequestID != "req_1" || result.ResolvedSearchType != "auto" || result.Output["content"] != "synth" {
- t.Fatalf("missing top-level Exa metadata: %#v", result)
+ if result.Query != "query" || result.RequestID != "req_1" || result.SearchContextSize != "medium" {
+ t.Fatalf("missing top-level search metadata: %#v", result)
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "costDollars") {
- t.Fatalf("Exa cost metadata leaked into search JSON: %s", string(raw))
+ t.Fatalf("provider cost metadata leaked into search JSON: %s", string(raw))
}
if len(result.Results) != 1 || result.Results[0].ID != "doc_1" || result.Results[0].Title != "One" || result.Results[0].Snippet != "hit" || result.Results[0].Text != "ok" {
t.Fatalf("unexpected search result %#v", result)
}
- if result.Results[0].Published != "2026-01-01" || result.Results[0].SiteName != "Example" || result.Results[0].Author != "A" {
- t.Fatalf("missing Exa metadata: %#v", result.Results[0])
+ if result.Results[0].Published != "2026-01-01" || result.Results[0].PublishedAt != "2026-01-01" || result.Results[0].SiteName != "Example" || result.Results[0].SiteNameSnake != "Example" || result.Results[0].Author != "A" {
+ t.Fatalf("missing search metadata: %#v", result.Results[0])
}
- if len(result.Results[0].Highlights) != 1 || result.Results[0].HighlightScores[0] != 0.5 || result.Results[0].Summary != "sum" {
- t.Fatalf("missing Exa content fields: %#v", result.Results[0])
+ if len(result.Results[0].Highlights) != 1 || result.Results[0].Summary != "sum" || result.Results[0].ImageURL == "" || result.Results[0].FaviconURL == "" {
+ t.Fatalf("missing search content fields: %#v", result.Results[0])
}
- if len(result.Results[0].Subpages) != 1 || len(result.Results[0].Entities) != 1 || result.Results[0].Extras["links"] == nil {
- t.Fatalf("missing Exa nested fields: %#v", result.Results[0])
+ if result.Results[0].Metadata["links"] == nil {
+ t.Fatalf("missing search metadata fields: %#v", result.Results[0])
}
- subpage := result.Results[0].Subpages[0]
- if subpage.Text != "sub text" || subpage.Summary != "sub sum" || len(subpage.Highlights) != 1 || subpage.HighlightScores[0] != 0.4 || subpage.Image == "" || subpage.Favicon == "" || subpage.Extras["links"] == nil {
- t.Fatalf("missing Exa subpage content fields: %#v", subpage)
+}
+
+func TestSearchIncludesErrorResponseMessage(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ _, _ = w.Write([]byte(`{"error":{"message":"Web tool upstream request timed out"}}`))
+ }))
+ defer server.Close()
+
+ _, err := Search(context.Background(), "query", 5, SearchRequestOptions{}, SearchOptions{Enabled: true, Endpoint: server.URL, APIKey: "key", Timeout: time.Second})
+ if err == nil || !strings.Contains(err.Error(), "Web tool upstream request timed out") {
+ t.Fatalf("expected upstream error message to propagate, got %v", err)
}
}
-func TestSearchMapsToolOptionsToExaPayload(t *testing.T) {
+func TestSearchMapsToolOptionsToPayload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
- if payload["type"] != "deep" || payload["category"] != "news" || payload["userLocation"] != "US" || payload["systemPrompt"] != "prefer official sources" {
+ if payload["category"] != "news" || payload["search_context_size"] != "high" {
t.Fatalf("missing scalar options %#v", payload)
}
- if domains, ok := payload["includeDomains"].([]any); !ok || len(domains) != 1 || domains[0] != "example.com" {
- t.Fatalf("missing includeDomains %#v", payload)
- }
- if moderation, ok := payload["moderation"].(bool); !ok || !moderation {
- t.Fatalf("missing moderation %#v", payload)
- }
- contents, ok := payload["contents"].(map[string]any)
- if !ok || contents["highlights"] != true {
- t.Fatalf("missing contents %#v", payload)
+ if domains, ok := payload["allowed_domains"].([]any); !ok || len(domains) != 1 || domains[0] != "example.com" {
+ t.Fatalf("missing allowed_domains %#v", payload)
}
- outputSchema, ok := payload["outputSchema"].(map[string]any)
- if !ok || outputSchema["type"] != "text" {
- t.Fatalf("missing outputSchema %#v", payload)
+ freshness, ok := payload["freshness"].(map[string]any)
+ if !ok || freshness["days"] != float64(7) || freshness["published_before"] != "2026-06-01T00:00:00Z" {
+ t.Fatalf("missing freshness %#v", payload)
}
_, _ = w.Write([]byte(`{"results":[]}`))
}))
@@ -314,22 +373,110 @@ func TestSearchMapsToolOptionsToExaPayload(t *testing.T) {
tool := WebSearchTool(SearchOptions{Enabled: true, Endpoint: server.URL, Timeout: time.Second})
_, err := tool.Execute(context.Background(), "call", map[string]any{
- "query": "query",
- "limit": 3,
- "includeDomains": []any{"example.com"},
- "type": "deep",
- "category": "news",
- "userLocation": "US",
- "moderation": true,
- "contents": map[string]any{"highlights": true},
- "outputSchema": map[string]any{"type": "text"},
- "systemPrompt": "prefer official sources",
+ "query": "query",
+ "limit": 3,
+ "allowed_domains": []any{"example.com"},
+ "search_context_size": "high",
+ "category": "news",
+ "freshness": map[string]any{"days": float64(7), "published_before": "2026-06-01T00:00:00Z"},
}, nil)
if err != nil {
t.Fatal(err)
}
}
+func TestSearchResultsCanBeFetchedByURL(t *testing.T) {
+ var fetchedURL string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/search":
+ _, _ = w.Write([]byte(`{"results":[{"title":"One","url":"https://example.com/one","snippet":"first"}]}`))
+ case "/fetch":
+ var payload map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ fetchedURL, _ = payload["url"].(string)
+ _, _ = w.Write([]byte(`{"url":"` + fetchedURL + `","final_url":"` + fetchedURL + `","title":"One","markdown":"Full page"}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+ client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host == "example.com" {
+ return testResponse(req, http.StatusOK, "text/html", "OneSearch result page"), nil
+ }
+ return http.DefaultTransport.RoundTrip(req)
+ })}
+
+ tools := Tools(
+ SessionInfo{},
+ FetchOptions{Timeout: time.Second, ToolEndpoint: server.URL + "/fetch", Client: client},
+ SearchOptions{Enabled: true, Endpoint: server.URL + "/search", Timeout: time.Second},
+ )
+ searchIndex := -1
+ fetchIndex := -1
+ for i := range tools {
+ switch tools[i].Tool.Name {
+ case "web_search":
+ searchIndex = i
+ case "fetch":
+ fetchIndex = i
+ }
+ }
+ if searchIndex < 0 || fetchIndex < 0 {
+ t.Fatalf("missing tools")
+ }
+ properties, _ := tools[fetchIndex].Tool.Parameters["properties"].(map[string]any)
+ if _, ok := properties["ref_id"]; ok {
+ t.Fatalf("fetch schema should not expose ref_id, got %#v", tools[fetchIndex].Tool.Parameters)
+ }
+ if _, ok := properties["url"]; !ok {
+ t.Fatalf("fetch schema should expose url, got %#v", tools[fetchIndex].Tool.Parameters)
+ }
+ required, _ := tools[fetchIndex].Tool.Parameters["required"].([]string)
+ if len(required) != 1 || required[0] != "url" {
+ t.Fatalf("fetch schema should require url, got %#v", tools[fetchIndex].Tool.Parameters)
+ }
+
+ searchResult, err := tools[searchIndex].Execute(context.Background(), "search-call", map[string]any{"query": "query"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var searchBody SearchResult
+ if err := json.Unmarshal([]byte(searchResult.Content[0].Text), &searchBody); err != nil {
+ t.Fatal(err)
+ }
+ if len(searchBody.Results) != 1 || searchBody.Results[0].URL != "https://example.com/one" {
+ t.Fatalf("missing URL in search result: %#v", searchBody)
+ }
+
+ fetchResult, err := tools[fetchIndex].Execute(context.Background(), "fetch-call", map[string]any{"url": searchBody.Results[0].URL}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fetchBody FetchResult
+ if err := json.Unmarshal([]byte(fetchResult.Content[0].Text), &fetchBody); err != nil {
+ t.Fatal(err)
+ }
+ if fetchedURL != "https://example.com/one" || fetchBody.Markdown != "Full page" {
+ t.Fatalf("unexpected fetch by URL: fetchedURL=%q result=%#v", fetchedURL, fetchBody)
+ }
+
+ fetchResult, err = tools[fetchIndex].Execute(context.Background(), "fetch-call-url", map[string]any{"url": "https://example.com/two"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fetchBody = FetchResult{}
+ if err := json.Unmarshal([]byte(fetchResult.Content[0].Text), &fetchBody); err != nil {
+ t.Fatal(err)
+ }
+ if fetchedURL != "https://example.com/two" || fetchBody.Markdown != "Full page" {
+ t.Fatalf("unexpected fetch by URL target: fetchedURL=%q result=%#v", fetchedURL, fetchBody)
+ }
+}
+
func TestSearchRequiresConfiguration(t *testing.T) {
if _, err := Search(context.Background(), "query", 5, SearchRequestOptions{}, SearchOptions{}); err == nil {
t.Fatalf("expected configuration error")
diff --git a/pkg/chattools/fetch.go b/pkg/chattools/fetch.go
index 78da6b38..42170007 100644
--- a/pkg/chattools/fetch.go
+++ b/pkg/chattools/fetch.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
+ "mime"
"net"
"net/http"
"net/url"
@@ -14,6 +15,7 @@ import (
"time"
"github.com/rs/zerolog"
+ "golang.org/x/net/html"
agent "github.com/beeper/ai-bridge/pkg/agent"
ai "github.com/beeper/ai-bridge/pkg/ai"
@@ -23,9 +25,9 @@ func FetchTool(options FetchOptions) agent.AgentTool[any] {
return agent.AgentTool[any]{
Tool: ai.Tool{
Name: "fetch",
- Description: "Fetch an HTTP or HTTPS URL and return readable page content, metadata, and source details.",
+ Description: "Fetch a URL and return readable page content.",
Parameters: objectSchema(map[string]any{
- "url": map[string]any{"type": "string", "description": "HTTP or HTTPS URL to fetch."},
+ "url": map[string]any{"type": "string", "description": "The full HTTP/HTTPS URL to fetch."},
"max_chars": map[string]any{"type": "integer", "description": "Maximum number of text characters to return."},
}, []string{"url"}),
},
@@ -64,7 +66,16 @@ func Fetch(ctx context.Context, rawURL string, options FetchOptions) (FetchResul
if options.MaxChars == 0 {
options.MaxChars = 20000
}
- if options.ExaEndpoint != "" && !shouldDirectFetch(parsed) {
+ directFirst := options.ToolEndpoint == "" || shouldReturnDirectURL(parsed)
+ var directResult FetchResult
+ var directErr error
+ if directFirst {
+ directResult, directErr = fetchDirect(ctx, rawURL, parsed, options)
+ if result, ok := directFetchResult(ctx, directResult, directErr, options); ok {
+ return result, nil
+ }
+ }
+ if options.ToolEndpoint != "" {
result, err := FetchContents(ctx, parsed.String(), options)
if err == nil {
return result, nil
@@ -73,12 +84,45 @@ func Fetch(ctx context.Context, rawURL string, options FetchOptions) (FetchResul
Err(err).
Str("action", "ai_tool_http").
Str("tool", "fetch").
- Str("fetch_method", "exa").
+ Str("fetch_method", "web_tool").
Str("target_url", parsed.Redacted()).
Str("target_host", parsed.Host).
- Msg("Falling back to direct fetch after Exa fetch failed")
+ Msg("Falling back to direct fetch result after web tool fetch failed")
}
- return fetchDirect(ctx, rawURL, parsed, options)
+ if !directFirst {
+ directResult, directErr = fetchDirect(ctx, rawURL, parsed, options)
+ if result, ok := directFetchResult(ctx, directResult, directErr, options); ok {
+ return result, nil
+ }
+ }
+ if directErr != nil {
+ return FetchResult{}, directErr
+ }
+ if !isHTTPSuccess(directResult.Status) {
+ return FetchResult{}, fmt.Errorf("direct fetch failed with HTTP %d", directResult.Status)
+ }
+ return directResult, nil
+}
+
+func directFetchResult(ctx context.Context, directResult FetchResult, directErr error, options FetchOptions) (FetchResult, bool) {
+ if directErr != nil || !isHTTPSuccess(directResult.Status) {
+ return FetchResult{}, false
+ }
+ if shouldReturnDirectResult(mustParseURL(directResult.FinalURL), directResult.ContentType) {
+ return directResult, true
+ }
+ if isHTMLContentType(directResult.ContentType) || looksLikeHTML(directResult.RawBody) {
+ if alternateURL := findReadableAlternate(directResult.ResponseHeaders, directResult.RawBody, directResult.FinalURL); alternateURL != "" {
+ if alternate, err := fetchDirectURL(ctx, alternateURL, options); err == nil && isHTTPSuccess(alternate.Status) && shouldReturnDirectResult(mustParseURL(alternate.FinalURL), alternate.ContentType) {
+ return alternate, true
+ }
+ }
+ }
+ return FetchResult{}, false
+}
+
+func isHTTPSuccess(status int) bool {
+ return status >= 200 && status < 300
}
func fetchDirect(ctx context.Context, rawURL string, parsed *url.URL, options FetchOptions) (FetchResult, error) {
@@ -98,6 +142,7 @@ func fetchDirect(ctx context.Context, rawURL string, parsed *url.URL, options Fe
return FetchResult{}, err
}
req.Header.Set("User-Agent", "beeper-ai-bridge/1.0")
+ req.Header.Set("Accept", directOpenAcceptHeader)
log.Trace().Msg("Sending AI tool HTTP request")
started := time.Now()
resp, err := client.Do(req)
@@ -123,27 +168,45 @@ func fetchDirect(ctx context.Context, rawURL string, parsed *url.URL, options Fe
truncated = true
}
metadata := extractHTMLMetadata(body, resp.Request.URL)
+ markdown := ""
+ if isDirectReadableContentType(resp.Header.Get("Content-Type")) || shouldReturnDirectURL(resp.Request.URL) {
+ markdown = text
+ }
return FetchResult{
- URL: rawURL,
- FinalURL: resp.Request.URL.String(),
- Status: resp.StatusCode,
- ContentType: resp.Header.Get("Content-Type"),
- Title: metadata.Title,
- Description: metadata.Description,
- Text: text,
- Favicon: metadata.Favicon,
- Truncated: truncated,
- FetchMethod: "direct",
+ URL: rawURL,
+ FinalURL: resp.Request.URL.String(),
+ Status: resp.StatusCode,
+ ContentType: resp.Header.Get("Content-Type"),
+ Title: metadata.Title,
+ Description: metadata.Description,
+ Text: text,
+ Markdown: markdown,
+ Favicon: metadata.Favicon,
+ Truncated: truncated,
+ FetchMethod: "direct",
+ ResponseHeaders: resp.Header.Clone(),
+ RawBody: body,
}, nil
}
+func fetchDirectURL(ctx context.Context, rawURL string, options FetchOptions) (FetchResult, error) {
+ parsed, err := url.Parse(rawURL)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return FetchResult{}, fmt.Errorf("invalid URL")
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return FetchResult{}, fmt.Errorf("unsupported URL scheme %s", parsed.Scheme)
+ }
+ return fetchDirect(ctx, rawURL, parsed, options)
+}
+
func FetchContents(ctx context.Context, rawURL string, options FetchOptions) (FetchResult, error) {
- if options.ExaEndpoint == "" {
+ if options.ToolEndpoint == "" {
return FetchResult{}, errors.New("fetch contents is not configured")
}
textMaxChars := options.MaxChars
- if textMaxChars <= 0 || textMaxChars > 10000 {
- textMaxChars = 10000
+ if textMaxChars <= 0 || textMaxChars > 50000 {
+ textMaxChars = 20000
}
client := options.Client
if client == nil {
@@ -153,21 +216,18 @@ func FetchContents(ctx context.Context, rawURL string, options FetchOptions) (Fe
if err != nil || target == nil || target.Scheme == "" || target.Host == "" {
return FetchResult{}, fmt.Errorf("invalid URL")
}
- log := toolHTTPLog(ctx, "fetch", http.MethodPost, options.ExaEndpoint).
+ log := toolHTTPLog(ctx, "fetch", http.MethodPost, options.ToolEndpoint).
With().
- Str("fetch_method", "exa").
+ Str("fetch_method", "web_tool").
Str("target_url", target.Redacted()).
Str("target_host", target.Host).
Logger()
ctx = log.WithContext(ctx)
payload, _ := json.Marshal(map[string]any{
- "urls": []string{rawURL},
- "text": map[string]any{
- "maxCharacters": textMaxChars,
- "verbosity": "standard",
- },
+ "url": rawURL,
+ "max_chars": textMaxChars,
})
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, options.ExaEndpoint, bytes.NewReader(payload))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, options.ToolEndpoint, bytes.NewReader(payload))
if err != nil {
return FetchResult{}, err
}
@@ -193,63 +253,53 @@ func FetchContents(ctx context.Context, rawURL string, options FetchOptions) (Fe
return FetchResult{}, err
}
result := FetchResult{
- URL: rawURL,
- FinalURL: rawURL,
- Status: 200,
- Truncated: false,
- RequestID: body.RequestID,
- Context: body.Context,
- FetchMethod: "exa",
- }
- if len(body.Statuses) > 0 {
- status := body.Statuses[0]
- result.Source = status.Source
- if status.Status == "error" {
- result.Status = status.Error.HTTPStatusCode
- if result.Status == 0 {
- result.Status = 502
- }
- result.Error = status.Error.Tag
- log.Error().
- Int("status_code", result.Status).
- Str("request_id", body.RequestID).
- Str("error_tag", status.Error.Tag).
- Msg("AI tool fetch provider returned item error")
- return result, fmt.Errorf("fetch contents failed: %s", firstNonEmpty(status.Error.Tag, status.Status))
- }
+ URL: firstNonEmpty(body.URL, rawURL),
+ FinalURL: firstNonEmpty(body.FinalURL, body.URL, rawURL),
+ Status: 200,
+ Title: body.Title,
+ Description: body.Description,
+ SiteName: body.SiteName,
+ Text: firstNonEmpty(body.Text, body.Markdown),
+ Markdown: firstNonEmpty(body.Markdown, body.Text),
+ Truncated: body.Truncated,
+ RequestID: firstNonEmpty(body.RequestID, body.RequestIDSnake),
+ RequestIDSnake: firstNonEmpty(body.RequestIDSnake, body.RequestID),
+ Published: firstNonEmpty(body.Published, body.PublishedAt, body.PublishedDate),
+ Author: body.Author,
+ Image: firstNonEmpty(body.Image, body.ImageURL),
+ ImageURL: firstNonEmpty(body.ImageURL, body.Image),
+ Favicon: firstNonEmpty(body.Favicon, body.FaviconURL),
+ FaviconURL: firstNonEmpty(body.FaviconURL, body.Favicon),
+ Extras: body.Metadata,
+ FetchMethod: "web_tool",
}
- if len(body.Results) == 0 {
- return result, nil
- }
- item := body.Results[0]
- result.FinalURL = firstNonEmpty(item.URL, rawURL)
- result.ID = item.ID
- result.Title = item.Title
- result.Description = item.Description
- result.Text = item.Text
- result.Published = firstNonEmpty(item.Published, item.PublishedDate)
- result.Author = item.Author
- result.Image = item.Image
- result.Favicon = item.Favicon
- result.Highlights = item.Highlights
- result.HighlightScores = item.HighlightScores
- result.Summary = item.Summary
- result.Subpages = item.Subpages
- result.Entities = item.Entities
- result.Extras = item.Extras
if len([]rune(result.Text)) > textMaxChars {
runes := []rune(result.Text)
result.Text = string(runes[:textMaxChars])
result.Truncated = true
}
+ if len([]rune(result.Markdown)) > textMaxChars {
+ runes := []rune(result.Markdown)
+ result.Markdown = string(runes[:textMaxChars])
+ result.Truncated = true
+ }
log.Debug().
- Str("request_id", body.RequestID).
+ Str("request_id", result.RequestID).
Bool("truncated", result.Truncated).
Msg("Parsed AI tool fetch result")
return result, nil
}
-func shouldDirectFetch(parsed *url.URL) bool {
+const directOpenAcceptHeader = "text/markdown, text/plain;q=0.95, application/json;q=0.9, application/xml;q=0.85, text/xml;q=0.85, text/csv;q=0.85, text/html;q=0.5, */*;q=0.1"
+
+func shouldReturnDirectResult(parsed *url.URL, contentType string) bool {
+ return isDirectReadableContentType(contentType) || shouldReturnDirectURL(parsed)
+}
+
+func shouldReturnDirectURL(parsed *url.URL) bool {
+ if parsed == nil {
+ return false
+ }
host := strings.ToLower(parsed.Hostname())
path := strings.ToLower(parsed.EscapedPath())
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
@@ -272,55 +322,190 @@ func shouldDirectFetch(parsed *url.URL) bool {
case ".txt", ".md", ".markdown", ".rst", ".csv", ".tsv", ".json", ".jsonl", ".yaml", ".yml", ".toml", ".xml", ".rss", ".atom",
".log", ".diff", ".patch",
".go", ".js", ".jsx", ".ts", ".tsx", ".css", ".scss", ".sass", ".less",
- ".c", ".cc", ".cpp", ".h", ".hpp", ".java", ".kt", ".kts", ".rs", ".py", ".rb", ".swift", ".sh", ".bash", ".zsh", ".fish", ".sql",
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".ico", ".svg",
- ".pdf", ".zip", ".tar", ".tgz", ".gz", ".bz2", ".xz", ".7z", ".rar",
- ".mp3", ".mp4", ".m4a", ".mov", ".wav", ".webm", ".ogg",
- ".woff", ".woff2", ".ttf", ".otf", ".eot", ".wasm",
- ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx":
+ ".c", ".cc", ".cpp", ".h", ".hpp", ".java", ".kt", ".kts", ".rs", ".py", ".rb", ".swift", ".sh", ".bash", ".zsh", ".fish", ".sql":
return true
default:
return false
}
}
-type contentsResponse struct {
- RequestID string `json:"requestId"`
- Context string `json:"context"`
- CostDollars map[string]any `json:"costDollars"`
- Results []contentsResultItem `json:"results"`
- Statuses []contentsStatusEntry `json:"statuses"`
+func isDirectReadableContentType(contentType string) bool {
+ mediaType := normalizedMediaType(contentType)
+ if mediaType == "" {
+ return false
+ }
+ if mediaType == "text/markdown" || mediaType == "text/plain" || mediaType == "text/csv" || mediaType == "text/xml" {
+ return true
+ }
+ if strings.HasPrefix(mediaType, "text/") && mediaType != "text/html" {
+ return true
+ }
+ if mediaType == "application/json" || mediaType == "application/xml" {
+ return true
+ }
+ return strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml")
+}
+
+func isHTMLContentType(contentType string) bool {
+ mediaType := normalizedMediaType(contentType)
+ return mediaType == "text/html" || mediaType == "application/xhtml+xml"
+}
+
+func looksLikeHTML(body []byte) bool {
+ prefix := strings.ToLower(string(bytes.TrimSpace(body)))
+ return strings.HasPrefix(prefix, "':
+ if !inQuote {
+ inAngle = false
+ }
+ case ',':
+ if !inQuote && !inAngle {
+ parts = append(parts, strings.TrimSpace(value[start:i]))
+ start = i + 1
+ }
+ }
+ }
+ parts = append(parts, strings.TrimSpace(value[start:]))
+ return parts
}
-type contentsStatusError struct {
- Tag string `json:"tag"`
- HTTPStatusCode int `json:"httpStatusCode"`
+func parseLinkValue(value string) (string, map[string]string) {
+ params := map[string]string{}
+ value = strings.TrimSpace(value)
+ if !strings.HasPrefix(value, "<") {
+ return "", params
+ }
+ end := strings.Index(value, ">")
+ if end < 0 {
+ return "", params
+ }
+ linkURL := strings.TrimSpace(value[1:end])
+ for _, part := range strings.Split(value[end+1:], ";") {
+ key, raw, ok := strings.Cut(strings.TrimSpace(part), "=")
+ if !ok {
+ continue
+ }
+ params[strings.ToLower(strings.TrimSpace(key))] = strings.Trim(strings.TrimSpace(raw), `"`)
+ }
+ return linkURL, params
+}
+
+func readableAlternateFromHTML(body []byte, baseURL *url.URL) string {
+ doc, err := html.Parse(bytes.NewReader(body))
+ if err != nil {
+ return ""
+ }
+ var out string
+ var walk func(*html.Node)
+ walk = func(node *html.Node) {
+ if out != "" {
+ return
+ }
+ if node.Type == html.ElementNode && strings.EqualFold(node.Data, "link") {
+ if relContains(attr(node, "rel"), "alternate") && isReadableAlternateType(attr(node, "type")) {
+ out = resolveMetadataURL(attr(node, "href"), baseURL)
+ return
+ }
+ }
+ for child := node.FirstChild; child != nil; child = child.NextSibling {
+ walk(child)
+ }
+ }
+ walk(doc)
+ return out
+}
+
+func relContains(rel string, token string) bool {
+ for _, part := range strings.Fields(strings.ToLower(rel)) {
+ if part == token {
+ return true
+ }
+ }
+ return false
+}
+
+func mustParseURL(rawURL string) *url.URL {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return nil
+ }
+ return parsed
+}
+
+type contentsResponse struct {
+ URL string `json:"url"`
+ FinalURL string `json:"final_url"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ SiteName string `json:"site_name"`
+ Published string `json:"published"`
+ PublishedAt string `json:"published_at"`
+ PublishedDate string `json:"publishedDate"`
+ Author string `json:"author"`
+ Image string `json:"image"`
+ ImageURL string `json:"image_url"`
+ Favicon string `json:"favicon"`
+ FaviconURL string `json:"favicon_url"`
+ Text string `json:"text"`
+ Markdown string `json:"markdown"`
+ Truncated bool `json:"truncated"`
+ Metadata map[string]any `json:"metadata"`
+ RequestID string `json:"requestId"`
+ RequestIDSnake string `json:"request_id"`
}
func toolHTTPLog(ctx context.Context, tool string, method string, rawURL string) zerolog.Logger {
diff --git a/pkg/chattools/search.go b/pkg/chattools/search.go
index 69692af4..8d578ec6 100644
--- a/pkg/chattools/search.go
+++ b/pkg/chattools/search.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
+ "strings"
"time"
agent "github.com/beeper/ai-bridge/pkg/agent"
@@ -18,26 +19,21 @@ func WebSearchTool(options SearchOptions) agent.AgentTool[any] {
return agent.AgentTool[any]{
Tool: ai.Tool{
Name: "web_search",
- Description: "Search the web with Exa and return relevant results with title, URL, content, highlights, summaries, and source metadata.",
+ Description: "Search the web and return relevant results with title, URL, snippets, readable content, and source metadata.",
Parameters: objectSchema(map[string]any{
- "query": map[string]any{"type": "string", "description": "Search query."},
- "limit": map[string]any{"type": "integer", "description": "Maximum number of results. Sent to Exa as numResults."},
- "includeDomains": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Domains or domain paths to include."},
- "excludeDomains": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Domains or domain paths to exclude."},
- "startCrawlDate": map[string]any{"type": "string", "description": "Only include results crawled after this ISO 8601 timestamp."},
- "endCrawlDate": map[string]any{"type": "string", "description": "Only include results crawled before this ISO 8601 timestamp."},
- "startPublishedDate": map[string]any{"type": "string", "description": "Only include results published after this ISO 8601 timestamp."},
- "endPublishedDate": map[string]any{"type": "string", "description": "Only include results published before this ISO 8601 timestamp."},
- "context": map[string]any{"description": "Deprecated Exa context option. Prefer contents.text, contents.highlights, or contents.summary."},
- "moderation": map[string]any{"type": "boolean", "description": "Enable Exa content moderation."},
- "contents": map[string]any{"type": "object", "description": "Exa contents options for text, highlights, summary, extras, freshness, and subpages."},
- "additionalQueries": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Additional deep-search query variations."},
- "type": map[string]any{"type": "string", "enum": []string{"instant", "fast", "auto", "deep-lite", "deep", "deep-reasoning"}, "description": "Exa search type."},
- "category": map[string]any{"type": "string", "enum": []string{"company", "research paper", "news", "personal site", "financial report", "people"}, "description": "Exa search category."},
- "userLocation": map[string]any{"type": "string", "description": "Two-letter country code used to bias results."},
- "compliance": map[string]any{"type": "string", "enum": []string{"hipaa"}, "description": "Enterprise-only compliance mode."},
- "outputSchema": map[string]any{"type": "object", "description": "Exa synthesis output schema. Do not combine with Exa streaming."},
- "systemPrompt": map[string]any{"type": "string", "description": "Additional Exa synthesis/search instructions."},
+ "query": map[string]any{"type": "string", "description": "Search query."},
+ "limit": map[string]any{"type": "integer", "description": "Maximum number of results, up to 10."},
+ "search_context_size": map[string]any{"type": "string", "enum": []string{"low", "medium", "high"}, "description": "Amount of page context to include in each result."},
+ "category": map[string]any{"type": "string", "enum": []string{"web", "news", "research", "company", "financial_report", "people"}, "description": "Optional result category."},
+ "allowed_domains": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Domains to include."},
+ "freshness": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "days": map[string]any{"type": "integer", "description": "Only include pages published in the last N days."},
+ "published_after": map[string]any{"type": "string", "description": "Only include pages published after this ISO 8601 timestamp."},
+ "published_before": map[string]any{"type": "string", "description": "Only include pages published before this ISO 8601 timestamp."},
+ },
+ },
}, []string{"query"}),
},
Execute: func(ctx context.Context, toolCallID string, params any, onUpdate agent.AgentToolUpdateCallback[any]) (agent.AgentToolResult[any], error) {
@@ -94,6 +90,10 @@ func Search(ctx context.Context, query string, limit int, request SearchRequestO
defer resp.Body.Close()
logToolHTTPResponse(log, resp, time.Since(started), "Received AI tool HTTP response")
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
+ if message := errorMessageFromBody(body); message != "" {
+ return SearchResult{}, fmt.Errorf("search failed with HTTP %d: %s", resp.StatusCode, message)
+ }
return SearchResult{}, fmt.Errorf("search failed with HTTP %d", resp.StatusCode)
}
var body searchResponse
@@ -110,17 +110,45 @@ func Search(ctx context.Context, query string, limit int, request SearchRequestO
}
log.Debug().
Str("request_id", result.RequestID).
- Str("resolved_search_type", result.ResolvedSearchType).
+ Str("search_context_size", result.SearchContextSize).
Int("result_count", len(result.Results)).
Msg("Parsed AI tool search result")
return result, nil
}
+func errorMessageFromBody(body []byte) string {
+ data := map[string]any{}
+ if err := json.Unmarshal(body, &data); err != nil {
+ return strings.TrimSpace(string(body))
+ }
+ if value := stringFromAnyValue(data["error"]); value != "" {
+ return value
+ }
+ if errorData, _ := data["error"].(map[string]any); errorData != nil {
+ if value := stringFromAnyValue(errorData["message"]); value != "" {
+ return value
+ }
+ }
+ if value := stringFromAnyValue(data["message"]); value != "" {
+ return value
+ }
+ return ""
+}
+
+func stringFromAnyValue(value any) string {
+ if text, ok := value.(string); ok {
+ return strings.TrimSpace(text)
+ }
+ return ""
+}
+
type searchResponse struct {
Query string `json:"query"`
RequestID string `json:"requestId"`
+ RequestIDSnake string `json:"request_id"`
ResolvedSearchType string `json:"resolvedSearchType"`
SearchType string `json:"searchType"`
+ SearchContextSize string `json:"search_context_size"`
Context string `json:"context"`
CostDollars map[string]any `json:"costDollars"`
Output map[string]any `json:"output"`
@@ -138,11 +166,15 @@ type searchResponseItem struct {
Summary string `json:"summary"`
Description string `json:"description"`
Published string `json:"published"`
+ PublishedAt string `json:"published_at"`
PublishedDate string `json:"publishedDate"`
SiteName string `json:"siteName"`
+ SiteNameSnake string `json:"site_name"`
Author string `json:"author"`
Image string `json:"image"`
+ ImageURL string `json:"image_url"`
Favicon string `json:"favicon"`
+ FaviconURL string `json:"favicon_url"`
Source string `json:"source"`
Subpages []SearchSubpage `json:"subpages"`
Entities []any `json:"entities"`
@@ -153,19 +185,18 @@ type searchResponseItem struct {
func (body searchResponse) result() SearchResult {
result := SearchResult{
Query: body.Query,
- RequestID: body.RequestID,
+ RequestID: firstNonEmpty(body.RequestID, body.RequestIDSnake),
+ RequestIDSnake: firstNonEmpty(body.RequestIDSnake, body.RequestID),
ResolvedSearchType: body.ResolvedSearchType,
SearchType: body.SearchType,
+ SearchContextSize: body.SearchContextSize,
Context: body.Context,
Output: body.Output,
Results: make([]SearchItem, 0, len(body.Results)),
}
for _, item := range body.Results {
snippet := firstNonEmpty(item.Snippet, firstString(item.Highlights), item.Summary, item.Text)
- published := item.Published
- if published == "" {
- published = item.PublishedDate
- }
+ published := firstNonEmpty(item.Published, item.PublishedAt, item.PublishedDate)
result.Results = append(result.Results, SearchItem{
ID: item.ID,
Title: item.Title,
@@ -177,11 +208,15 @@ func (body searchResponse) result() SearchResult {
Summary: item.Summary,
Description: item.Description,
Published: published,
+ PublishedAt: firstNonEmpty(item.PublishedAt, published),
PublishedDate: item.PublishedDate,
- SiteName: item.SiteName,
+ SiteName: firstNonEmpty(item.SiteName, item.SiteNameSnake),
+ SiteNameSnake: firstNonEmpty(item.SiteNameSnake, item.SiteName),
Author: item.Author,
- Image: item.Image,
- Favicon: item.Favicon,
+ Image: firstNonEmpty(item.Image, item.ImageURL),
+ ImageURL: firstNonEmpty(item.ImageURL, item.Image),
+ Favicon: firstNonEmpty(item.Favicon, item.FaviconURL),
+ FaviconURL: firstNonEmpty(item.FaviconURL, item.Favicon),
Source: item.Source,
Subpages: item.Subpages,
Entities: item.Entities,
@@ -194,28 +229,23 @@ func (body searchResponse) result() SearchResult {
func searchPayload(query string, limit int, request SearchRequestOptions) map[string]any {
payload := map[string]any{
- "query": query,
- "numResults": limit,
- "useAutoprompt": false,
- }
- addStrings(payload, "includeDomains", request.IncludeDomains)
- addStrings(payload, "excludeDomains", request.ExcludeDomains)
- addString(payload, "startCrawlDate", request.StartCrawlDate)
- addString(payload, "endCrawlDate", request.EndCrawlDate)
- addString(payload, "startPublishedDate", request.StartPublishedDate)
- addString(payload, "endPublishedDate", request.EndPublishedDate)
- addAny(payload, "context", request.Context)
- if request.Moderation != nil {
- payload["moderation"] = *request.Moderation
- }
- addMap(payload, "contents", request.Contents)
- addStrings(payload, "additionalQueries", request.AdditionalQueries)
- addString(payload, "type", request.Type)
+ "query": query,
+ "limit": limit,
+ }
+ addString(payload, "search_context_size", request.SearchContextSize)
addString(payload, "category", request.Category)
- addString(payload, "userLocation", request.UserLocation)
- addString(payload, "compliance", request.Compliance)
- addMap(payload, "outputSchema", request.OutputSchema)
- addString(payload, "systemPrompt", request.SystemPrompt)
+ addStrings(payload, "allowed_domains", request.AllowedDomains)
+ if request.Freshness != nil {
+ freshness := map[string]any{}
+ if request.Freshness.Days > 0 {
+ freshness["days"] = request.Freshness.Days
+ }
+ addString(freshness, "published_after", request.Freshness.PublishedAfter)
+ addString(freshness, "published_before", request.Freshness.PublishedBefore)
+ if len(freshness) > 0 {
+ payload["freshness"] = freshness
+ }
+ }
return payload
}
@@ -225,25 +255,15 @@ func requestOptions(params any) SearchRequestOptions {
return SearchRequestOptions{}
}
var out SearchRequestOptions
- out.IncludeDomains = stringSliceParam(values, "includeDomains")
- out.ExcludeDomains = stringSliceParam(values, "excludeDomains")
- out.StartCrawlDate = stringValueParam(values, "startCrawlDate")
- out.EndCrawlDate = stringValueParam(values, "endCrawlDate")
- out.StartPublishedDate = stringValueParam(values, "startPublishedDate")
- out.EndPublishedDate = stringValueParam(values, "endPublishedDate")
- if value, ok := values["context"]; ok {
- out.Context = value
- }
- if value, ok := values["moderation"].(bool); ok {
- out.Moderation = &value
- }
- out.Contents = mapParam(values, "contents")
- out.AdditionalQueries = stringSliceParam(values, "additionalQueries")
- out.Type = stringValueParam(values, "type")
+ out.SearchContextSize = stringValueParam(values, "search_context_size")
out.Category = stringValueParam(values, "category")
- out.UserLocation = stringValueParam(values, "userLocation")
- out.Compliance = stringValueParam(values, "compliance")
- out.OutputSchema = mapParam(values, "outputSchema")
- out.SystemPrompt = stringValueParam(values, "systemPrompt")
+ out.AllowedDomains = stringSliceParam(values, "allowed_domains")
+ if freshness := mapParam(values, "freshness"); freshness != nil {
+ out.Freshness = &SearchFreshness{
+ Days: intParam(freshness, "days", 0),
+ PublishedAfter: stringValueParam(freshness, "published_after"),
+ PublishedBefore: stringValueParam(freshness, "published_before"),
+ }
+ }
return out
}
diff --git a/pkg/chattools/session.go b/pkg/chattools/session.go
index 3033b74a..02edc403 100644
--- a/pkg/chattools/session.go
+++ b/pkg/chattools/session.go
@@ -16,15 +16,15 @@ func GetSessionToolWithOptions(info SessionInfo, options SessionOptions) agent.A
return agent.AgentTool[any]{
Tool: ai.Tool{
Name: "get_session",
- Description: "Get fresh metadata for this Beeper AI chat, including current UTC timestamp, chat ID/title, selected model, selected reasoning, disabled tools, approved profile fields, and last known UTC timestamp.",
+ Description: "Get fresh metadata for this Beeper AI chat, including current UTC timestamp, chat ID/title, selected model, selected reasoning, disabled tools, approved profile fields, last message UTC timestamp, and last known timezone.",
Parameters: objectSchema(nil, nil),
},
Execute: func(ctx context.Context, toolCallID string, params any, onUpdate agent.AgentToolUpdateCallback[any]) (agent.AgentToolResult[any], error) {
now := time.Now().UTC()
current := info
current.CurrentTimestamp = now.Format(time.RFC3339)
- if current.LastKnownTimestamp == "" {
- current.LastKnownTimestamp = current.CurrentTimestamp
+ if current.LastMessageTimestamp == "" {
+ current.LastMessageTimestamp = current.CurrentTimestamp
}
if options.ResolveProfile != nil {
profile, err := options.ResolveProfile(ctx, toolCallID)
diff --git a/pkg/chattools/types.go b/pkg/chattools/types.go
index 6fd8078d..036f5990 100644
--- a/pkg/chattools/types.go
+++ b/pkg/chattools/types.go
@@ -7,18 +7,21 @@ import (
)
type SessionInfo struct {
- CurrentTimestamp string `json:"current_timestamp"`
- ChatID string `json:"chat_id,omitempty"`
- ChatTitle string `json:"chat_title,omitempty"`
- ChatFirstMessageAt string `json:"chat_first_message_at,omitempty"`
- SelectedModel string `json:"selected_model,omitempty"`
- SelectedReasoning string `json:"selected_reasoning,omitempty"`
- DisabledTools []string `json:"disabled_tools,omitempty"`
- BeeperUsername string `json:"beeper_username,omitempty"`
- BeeperDisplayName string `json:"beeper_display_name,omitempty"`
- BeeperAccountEmail string `json:"beeper_account_email,omitempty"`
- GravatarProfile any `json:"gravatar_profile,omitempty"`
- LastKnownTimestamp string `json:"last_known_timestamp"`
+ CurrentTimestamp string `json:"current_timestamp"`
+ ChatID string `json:"chat_id,omitempty"`
+ ChatTitle string `json:"chat_title,omitempty"`
+ ChatFirstMessageAt string `json:"chat_first_message_at,omitempty"`
+ SelectedModel string `json:"selected_model,omitempty"`
+ SelectedReasoning string `json:"selected_reasoning,omitempty"`
+ DisabledTools []string `json:"disabled_tools,omitempty"`
+ SearchMode string `json:"search_mode,omitempty"`
+ FetchMode string `json:"fetch_mode,omitempty"`
+ BeeperUsername string `json:"beeper_username,omitempty"`
+ BeeperDisplayName string `json:"beeper_display_name,omitempty"`
+ BeeperAccountEmail string `json:"beeper_account_email,omitempty"`
+ GravatarProfile any `json:"gravatar_profile,omitempty"`
+ LastMessageTimestamp string `json:"last_message_timestamp"`
+ LastKnownTimezone string `json:"last_known_timezone,omitempty"`
}
type SessionProfile struct {
@@ -34,12 +37,13 @@ type SessionOptions struct {
}
type FetchOptions struct {
- Timeout time.Duration
- MaxBytes int64
- MaxChars int
- Client *http.Client
- ExaEndpoint string
- APIKey string
+ Disabled bool
+ Timeout time.Duration
+ MaxBytes int64
+ MaxChars int
+ Client *http.Client
+ ToolEndpoint string
+ APIKey string
}
type SearchOptions struct {
@@ -51,22 +55,16 @@ type SearchOptions struct {
}
type SearchRequestOptions struct {
- IncludeDomains []string `json:"includeDomains,omitempty"`
- ExcludeDomains []string `json:"excludeDomains,omitempty"`
- StartCrawlDate string `json:"startCrawlDate,omitempty"`
- EndCrawlDate string `json:"endCrawlDate,omitempty"`
- StartPublishedDate string `json:"startPublishedDate,omitempty"`
- EndPublishedDate string `json:"endPublishedDate,omitempty"`
- Context any `json:"context,omitempty"`
- Moderation *bool `json:"moderation,omitempty"`
- Contents map[string]any `json:"contents,omitempty"`
- AdditionalQueries []string `json:"additionalQueries,omitempty"`
- Type string `json:"type,omitempty"`
- Category string `json:"category,omitempty"`
- UserLocation string `json:"userLocation,omitempty"`
- Compliance string `json:"compliance,omitempty"`
- OutputSchema map[string]any `json:"outputSchema,omitempty"`
- SystemPrompt string `json:"systemPrompt,omitempty"`
+ SearchContextSize string `json:"search_context_size,omitempty"`
+ Category string `json:"category,omitempty"`
+ AllowedDomains []string `json:"allowed_domains,omitempty"`
+ Freshness *SearchFreshness `json:"freshness,omitempty"`
+}
+
+type SearchFreshness struct {
+ Days int `json:"days,omitempty"`
+ PublishedAfter string `json:"published_after,omitempty"`
+ PublishedBefore string `json:"published_before,omitempty"`
}
type FetchResult struct {
@@ -76,13 +74,17 @@ type FetchResult struct {
ContentType string `json:"content_type,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
+ SiteName string `json:"site_name,omitempty"`
Text string `json:"text,omitempty"`
+ Markdown string `json:"markdown,omitempty"`
Truncated bool `json:"truncated"`
ID string `json:"id,omitempty"`
Published string `json:"published,omitempty"`
Author string `json:"author,omitempty"`
Image string `json:"image,omitempty"`
+ ImageURL string `json:"image_url,omitempty"`
Favicon string `json:"favicon,omitempty"`
+ FaviconURL string `json:"favicon_url,omitempty"`
Highlights []string `json:"highlights,omitempty"`
HighlightScores []float64 `json:"highlightScores,omitempty"`
Summary any `json:"summary,omitempty"`
@@ -90,17 +92,22 @@ type FetchResult struct {
Entities []any `json:"entities,omitempty"`
Extras map[string]any `json:"extras,omitempty"`
Source string `json:"source,omitempty"`
- RequestID string `json:"requestId,omitempty"`
+ RequestID string `json:"-"`
+ RequestIDSnake string `json:"-"`
Context string `json:"context,omitempty"`
Error string `json:"error,omitempty"`
FetchMethod string `json:"-"`
+ ResponseHeaders http.Header `json:"-"`
+ RawBody []byte `json:"-"`
}
type SearchResult struct {
Query string `json:"query"`
- RequestID string `json:"requestId,omitempty"`
+ RequestID string `json:"-"`
+ RequestIDSnake string `json:"-"`
ResolvedSearchType string `json:"resolvedSearchType,omitempty"`
SearchType string `json:"searchType,omitempty"`
+ SearchContextSize string `json:"search_context_size,omitempty"`
Context string `json:"context,omitempty"`
Output map[string]any `json:"output,omitempty"`
Results []SearchItem `json:"results"`
@@ -117,11 +124,15 @@ type SearchItem struct {
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Published string `json:"published,omitempty"`
+ PublishedAt string `json:"published_at,omitempty"`
PublishedDate string `json:"publishedDate,omitempty"`
SiteName string `json:"siteName,omitempty"`
+ SiteNameSnake string `json:"site_name,omitempty"`
Author string `json:"author,omitempty"`
Image string `json:"image,omitempty"`
+ ImageURL string `json:"image_url,omitempty"`
Favicon string `json:"favicon,omitempty"`
+ FaviconURL string `json:"favicon_url,omitempty"`
Source string `json:"source,omitempty"`
Subpages []SearchSubpage `json:"subpages,omitempty"`
Entities []any `json:"entities,omitempty"`
diff --git a/pkg/connector/approvals.go b/pkg/connector/approvals.go
index 640619a1..d7756c45 100644
--- a/pkg/connector/approvals.go
+++ b/pkg/connector/approvals.go
@@ -323,12 +323,12 @@ func (cl *Client) fetchBeeperProfile(ctx context.Context) (*chattools.SessionPro
}, nil
}
-func aiServicesWhoamiURL(proxyBaseURL string) (string, error) {
- parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(proxyBaseURL), "/"))
+func aiServicesWhoamiURL(baseURL string) (string, error) {
+ parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(baseURL), "/"))
if err != nil {
return "", err
}
- parsed.Path = strings.TrimRight(trimAIProxyProviderPath(parsed.Path), "/") + "/whoami"
+ parsed.Path = strings.TrimRight(parsed.Path, "/") + "/whoami"
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
diff --git a/pkg/connector/bridge_commands.go b/pkg/connector/bridge_commands.go
index 4938f509..963b7dd6 100644
--- a/pkg/connector/bridge_commands.go
+++ b/pkg/connector/bridge_commands.go
@@ -39,6 +39,7 @@ func (c *Connector) registerAICommands() {
processor.AddHandlers(
c.bridgeAICommand("model", "Show or set the AI model for this room.", "[model]"),
c.bridgeAICommand("reasoning", "Show or set the reasoning level for this room.", "[off|minimal|low|medium|high|xhigh]"),
+ c.bridgeAICommand("reasoning-mode", "Show or set the reasoning mode for this room.", "[default|adaptive]"),
c.bridgeAICommand("system-prompt", "Show, set, or clear this room's additional system prompt.", "[prompt|clear]"),
c.bridgeAICommand("abort", "Abort the active AI response or compaction.", ""),
c.bridgeAICommand("stop", "Stop the active AI response or compaction.", ""),
@@ -222,14 +223,14 @@ func (c *Connector) handleBridgeProviderUpsert(ce *commands.Event, action string
if len(fields) == 5 {
input.DefaultModel = fields[4]
}
- provider, err := c.VerifyProviderConfig(ce.Ctx, input)
+ login, err := c.loginForBridgeProviderCommand(ce)
if err != nil {
- ce.Reply("Provider rejected: %v", err)
+ ce.Reply(err.Error())
return
}
- login, err := c.loginForBridgeProviderCommand(ce)
+ provider, err := c.VerifyProviderConfig(ce.Ctx, login, input)
if err != nil {
- ce.Reply(err.Error())
+ ce.Reply("Provider rejected: %v", err)
return
}
if err = c.SaveProviderConfig(ce.Ctx, login, provider); err != nil {
diff --git a/pkg/connector/builtin_tools.go b/pkg/connector/builtin_tools.go
index bcf03ea1..f4819854 100644
--- a/pkg/connector/builtin_tools.go
+++ b/pkg/connector/builtin_tools.go
@@ -2,21 +2,40 @@ package connector
import (
"context"
- "maps"
"slices"
+ "strings"
"github.com/beeper/ai-bridge/pkg/agent/harness"
+ "github.com/beeper/ai-bridge/pkg/ai"
)
-func (cl *Client) registerProviderBuiltInToolHooks(agentHarness *harness.AgentHarness) {
+const anthropicWebFetchBetaMetadataKey = "anthropic_web_fetch_beta"
+
+func (cl *Client) registerProviderBuiltInToolHooks(agentHarness *harness.AgentHarness, roomConfig RoomConfig) {
if agentHarness == nil {
return
}
+ agentHarness.On("before_provider_request", func(_ context.Context, event harness.AgentHarnessEvent) (any, error) {
+ if event.Model == nil || roomFetchMode(roomConfig) != toolModeNative || event.Model.API != ai.ApiAnthropicMessages {
+ return nil, nil
+ }
+ if !modelSupportsBuiltInTool(*event.Model, "web_fetch") {
+ return nil, nil
+ }
+ if _, ok := nativeWebFetchToolPayload(*event.Model); !ok {
+ return nil, nil
+ }
+ return harness.BeforeProviderRequestResult{
+ StreamOptions: harness.AgentHarnessStreamOptions{
+ Metadata: map[string]any{anthropicWebFetchBetaMetadataKey: true},
+ },
+ }, nil
+ })
agentHarness.On("before_provider_payload", func(_ context.Context, event harness.AgentHarnessEvent) (any, error) {
if event.Model == nil {
return nil, nil
}
- payload, changed := addBuiltInToolsToPayload(event.Payload, event.Model.BuiltInTools)
+ payload, changed := addBuiltInToolsToPayload(event.Payload, activeBuiltInToolPayloads(*event.Model, roomConfig))
if !changed {
return nil, nil
}
@@ -24,7 +43,129 @@ func (cl *Client) registerProviderBuiltInToolHooks(agentHarness *harness.AgentHa
})
}
-func addBuiltInToolsToPayload(payload any, builtInTools []string) (any, bool) {
+func activeBuiltInToolPayloads(model ai.Model, roomConfig RoomConfig) []map[string]any {
+ out := make([]map[string]any, 0, len(model.BuiltInTools)+2)
+ if roomSearchMode(roomConfig) == toolModeNative && modelSupportsBuiltInTool(model, "web_search") {
+ if payload, ok := nativeWebSearchToolPayload(model); ok {
+ out = appendBuiltInToolPayload(out, payload)
+ }
+ }
+ if roomFetchMode(roomConfig) == toolModeNative && modelSupportsBuiltInTool(model, "web_fetch") {
+ if payload, ok := nativeWebFetchToolPayload(model); ok {
+ out = appendBuiltInToolPayload(out, payload)
+ }
+ }
+ for _, tool := range model.BuiltInTools {
+ payload, ok := builtInToolPayload(model, roomConfig, tool)
+ if !ok {
+ continue
+ }
+ out = appendBuiltInToolPayload(out, payload)
+ }
+ return out
+}
+
+func modelSupportsBuiltInTool(model ai.Model, tool string) bool {
+ canonical := normalizedBuiltInTool(tool)
+ for _, supported := range model.BuiltInTools {
+ if normalizedBuiltInTool(supported) == canonical {
+ return true
+ }
+ }
+ return false
+}
+
+func builtInToolPayload(model ai.Model, roomConfig RoomConfig, tool string) (map[string]any, bool) {
+ switch normalizedBuiltInTool(tool) {
+ case "web_search":
+ if roomSearchMode(roomConfig) != toolModeNative {
+ return nil, false
+ }
+ return nativeWebSearchToolPayload(model)
+ case "web_fetch":
+ if roomFetchMode(roomConfig) != toolModeNative {
+ return nil, false
+ }
+ return nativeWebFetchToolPayload(model)
+ case "image_generation":
+ switch {
+ case strings.HasPrefix(strings.TrimSpace(tool), "openrouter:"):
+ return map[string]any{"type": strings.TrimSpace(tool)}, true
+ case model.Provider == ai.ProviderOpenAI || model.Provider == ai.ProviderOpenRouter:
+ return map[string]any{"type": "image_generation"}, true
+ default:
+ return nil, false
+ }
+ default:
+ return nil, false
+ }
+}
+
+func normalizedBuiltInTool(tool string) string {
+ tool = strings.ToLower(strings.TrimSpace(tool))
+ switch tool {
+ case "web_search", "web_search_preview", "openrouter:web_search", "web_search_20250305", "google_search", "google_search_retrieval":
+ return "web_search"
+ case "web_fetch", "openrouter:web_fetch", "web_fetch_20250910", "web_fetch_20260209", "url_context", "urlcontext":
+ return "web_fetch"
+ case "image_generation", "openrouter:image_generation":
+ return "image_generation"
+ default:
+ return tool
+ }
+}
+
+func nativeWebSearchToolPayload(model ai.Model) (map[string]any, bool) {
+ switch model.API {
+ case ai.ApiAnthropicMessages:
+ return map[string]any{"type": "web_search_20250305", "name": "web_search"}, true
+ case ai.ApiGoogleGenerativeAI, ai.ApiGoogleVertex:
+ return map[string]any{"google_search": map[string]any{}}, true
+ case ai.ApiOpenAIResponses:
+ if model.Provider == ai.ProviderOpenRouter {
+ return map[string]any{"type": "openrouter:web_search"}, true
+ }
+ return map[string]any{"type": "web_search"}, true
+ case ai.ApiOpenAICompletions:
+ if model.Provider == ai.ProviderOpenRouter {
+ return map[string]any{"type": "openrouter:web_search"}, true
+ }
+ return nil, false
+ default:
+ return nil, false
+ }
+}
+
+func nativeWebFetchToolPayload(model ai.Model) (map[string]any, bool) {
+ switch model.API {
+ case ai.ApiAnthropicMessages:
+ return map[string]any{"type": "web_fetch_20250910", "name": "web_fetch", "citations": map[string]any{"enabled": true}}, true
+ case ai.ApiGoogleGenerativeAI, ai.ApiGoogleVertex:
+ return map[string]any{"url_context": map[string]any{}}, true
+ case ai.ApiOpenAIResponses, ai.ApiOpenAICompletions:
+ if model.Provider == ai.ProviderOpenRouter {
+ return map[string]any{"type": "openrouter:web_fetch"}, true
+ }
+ return nil, false
+ default:
+ return nil, false
+ }
+}
+
+func appendBuiltInToolPayload(payloads []map[string]any, payload map[string]any) []map[string]any {
+ key := builtInToolKey(payload)
+ if key == "" {
+ return payloads
+ }
+ for _, existing := range payloads {
+ if builtInToolKey(existing) == key {
+ return payloads
+ }
+ }
+ return append(payloads, payload)
+}
+
+func addBuiltInToolsToPayload(payload any, builtInTools []map[string]any) (any, bool) {
body, ok := payload.(map[string]any)
if !ok || len(builtInTools) == 0 {
return payload, false
@@ -33,9 +174,9 @@ func addBuiltInToolsToPayload(payload any, builtInTools []string) (any, bool) {
next := clonePayloadMap(body)
tools := toolsAsAny(next["tools"])
changed := false
- for _, toolType := range builtInTools {
+ for _, toolPayload := range builtInTools {
before := len(tools)
- tools = appendBuiltInTool(tools, toolType)
+ tools = appendBuiltInTool(tools, toolPayload)
changed = changed || len(tools) != before
}
if !changed {
@@ -45,27 +186,46 @@ func addBuiltInToolsToPayload(payload any, builtInTools []string) (any, bool) {
return next, true
}
-func appendBuiltInTool(tools []any, toolType string) []any {
+func appendBuiltInTool(tools []any, toolPayload map[string]any) []any {
+ toolKey := builtInToolKey(toolPayload)
+ if toolKey == "" {
+ return tools
+ }
for _, tool := range tools {
if toolMap, ok := tool.(map[string]any); ok {
- if toolMap["type"] == toolType || toolMap["name"] == toolType {
+ if builtInToolKey(toolMap) == toolKey {
return tools
}
- if toolMap["type"] == "function" && toolMap["name"] == builtInToolFunctionName(toolType) {
+ if toolMap["type"] == "function" && toolMap["name"] == toolKey {
return tools
}
}
}
- return append(tools, map[string]any{"type": toolType})
+ return append(tools, clonePayloadMap(toolPayload))
}
-func builtInToolFunctionName(toolType string) string {
- switch toolType {
- case "image_generation":
- return "image_generation"
- default:
- return toolType
+func builtInToolKey(tool map[string]any) string {
+ if tool == nil {
+ return ""
+ }
+ for _, key := range []string{"type", "name"} {
+ if value := strings.TrimSpace(stringFromAny(tool[key])); value != "" {
+ return value
+ }
+ }
+ if _, ok := tool["google_search"]; ok {
+ return "google_search"
}
+ if _, ok := tool["googleSearch"]; ok {
+ return "google_search"
+ }
+ if _, ok := tool["url_context"]; ok {
+ return "url_context"
+ }
+ if _, ok := tool["urlContext"]; ok {
+ return "url_context"
+ }
+ return ""
}
func toolsAsAny(raw any) []any {
@@ -84,5 +244,37 @@ func toolsAsAny(raw any) []any {
}
func clonePayloadMap(in map[string]any) map[string]any {
- return maps.Clone(in)
+ return cloneAnyMap(in)
+}
+
+func cloneAnyMap(in map[string]any) map[string]any {
+ if in == nil {
+ return nil
+ }
+ out := make(map[string]any, len(in))
+ for key, value := range in {
+ out[key] = cloneAny(value)
+ }
+ return out
+}
+
+func cloneAny(value any) any {
+ switch typed := value.(type) {
+ case map[string]any:
+ return cloneAnyMap(typed)
+ case []any:
+ out := make([]any, len(typed))
+ for i, item := range typed {
+ out[i] = cloneAny(item)
+ }
+ return out
+ case []map[string]any:
+ out := make([]map[string]any, len(typed))
+ for i, item := range typed {
+ out[i] = cloneAnyMap(item)
+ }
+ return out
+ default:
+ return value
+ }
}
diff --git a/pkg/connector/builtin_tools_test.go b/pkg/connector/builtin_tools_test.go
index 71465ecf..0571e44e 100644
--- a/pkg/connector/builtin_tools_test.go
+++ b/pkg/connector/builtin_tools_test.go
@@ -1,6 +1,10 @@
package connector
-import "testing"
+import (
+ "testing"
+
+ "github.com/beeper/ai-bridge/pkg/ai"
+)
func TestAddBuiltInToolsToPayloadAddsCatalogBuiltIns(t *testing.T) {
payload := map[string]any{
@@ -11,7 +15,7 @@ func TestAddBuiltInToolsToPayloadAddsCatalogBuiltIns(t *testing.T) {
}},
}
- next, changed := addBuiltInToolsToPayload(payload, []string{"image_generation"})
+ next, changed := addBuiltInToolsToPayload(payload, []map[string]any{{"type": "image_generation"}})
if !changed {
t.Fatal("expected payload to change")
}
@@ -43,7 +47,7 @@ func TestAddBuiltInToolsToPayloadSkipsWhenAlreadyPresent(t *testing.T) {
}},
}
- _, changed := addBuiltInToolsToPayload(payload, []string{"image_generation"})
+ _, changed := addBuiltInToolsToPayload(payload, []map[string]any{{"type": "image_generation"}})
if changed {
t.Fatal("did not expect payload change when built-in tool is already present")
}
@@ -52,13 +56,161 @@ func TestAddBuiltInToolsToPayloadSkipsWhenAlreadyPresent(t *testing.T) {
func TestAddBuiltInToolsToPayloadAddsOpenRouterBuiltIn(t *testing.T) {
payload := map[string]any{"model": "anthropic/claude-sonnet-4.5"}
- next, changed := addBuiltInToolsToPayload(payload, []string{"openrouter:image_generation"})
+ next, changed := addBuiltInToolsToPayload(payload, []map[string]any{{"type": "openrouter:image_generation"}})
if !changed {
t.Fatal("expected payload to change")
}
assertToolType(t, next.(map[string]any)["tools"], "openrouter:image_generation")
}
+func TestActiveBuiltInToolPayloadsHonorsNativeSearchMode(t *testing.T) {
+ model := ai.Model{API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenAI, BuiltInTools: []string{"web_search", "image_generation"}}
+ if got := activeBuiltInToolPayloads(model, RoomConfig{}); len(got) != 1 || got[0]["type"] != "image_generation" {
+ t.Fatalf("default beeper search should suppress native web_search only, got %#v", got)
+ }
+ if got := activeBuiltInToolPayloads(model, RoomConfig{SearchMode: toolModeNative}); len(got) != 2 || got[0]["type"] != "web_search" || got[1]["type"] != "image_generation" {
+ t.Fatalf("native search should allow provider web_search, got %#v", got)
+ }
+ if got := activeBuiltInToolPayloads(model, RoomConfig{SearchMode: toolModeOff}); len(got) != 1 || got[0]["type"] != "image_generation" {
+ t.Fatalf("off search should suppress native web_search only, got %#v", got)
+ }
+}
+
+func TestActiveBuiltInToolPayloadsHonorsNativeFetchMode(t *testing.T) {
+ model := ai.Model{API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenRouter, BuiltInTools: []string{"web_fetch", "openrouter:image_generation"}}
+ if got := activeBuiltInToolPayloads(model, RoomConfig{}); len(got) != 1 || got[0]["type"] != "openrouter:image_generation" {
+ t.Fatalf("default beeper fetch should suppress native web_fetch only, got %#v", got)
+ }
+ if got := activeBuiltInToolPayloads(model, RoomConfig{FetchMode: toolModeNative}); len(got) != 2 || got[0]["type"] != "openrouter:web_fetch" || got[1]["type"] != "openrouter:image_generation" {
+ t.Fatalf("native fetch should allow provider web_fetch, got %#v", got)
+ }
+ if got := activeBuiltInToolPayloads(model, RoomConfig{FetchMode: toolModeOff}); len(got) != 1 || got[0]["type"] != "openrouter:image_generation" {
+ t.Fatalf("off fetch should suppress native web_fetch only, got %#v", got)
+ }
+}
+
+func TestActiveBuiltInToolPayloadsDoesNotInjectNativeModesWithoutCatalogBuiltIns(t *testing.T) {
+ model := ai.Model{API: ai.ApiGoogleGenerativeAI, Provider: ai.ProviderGoogle}
+ got := activeBuiltInToolPayloads(model, RoomConfig{SearchMode: toolModeNative, FetchMode: toolModeNative})
+ if len(got) != 0 {
+ t.Fatalf("native modes should not synthesize unsupported catalog tools, got %#v", got)
+ }
+}
+
+func TestNativeWebSearchToolPayloadsAreProviderSpecific(t *testing.T) {
+ tests := []struct {
+ name string
+ model ai.Model
+ wantKey string
+ wantValue any
+ }{
+ {
+ name: "openai responses",
+ model: ai.Model{API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenAI, BuiltInTools: []string{"web_search"}},
+ wantKey: "type",
+ wantValue: "web_search",
+ },
+ {
+ name: "openrouter",
+ model: ai.Model{API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenRouter, BuiltInTools: []string{"web_search"}},
+ wantKey: "type",
+ wantValue: "openrouter:web_search",
+ },
+ {
+ name: "anthropic",
+ model: ai.Model{API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic, BuiltInTools: []string{"web_search"}},
+ wantKey: "type",
+ wantValue: "web_search_20250305",
+ },
+ {
+ name: "google vertex",
+ model: ai.Model{API: ai.ApiGoogleVertex, Provider: ai.ProviderGoogleVertex, BuiltInTools: []string{"web_search"}},
+ wantKey: "google_search",
+ wantValue: map[string]any{},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := activeBuiltInToolPayloads(tt.model, RoomConfig{SearchMode: toolModeNative})
+ if len(got) != 1 {
+ t.Fatalf("expected one native search payload, got %#v", got)
+ }
+ if tt.wantKey == "google_search" {
+ if _, ok := got[0]["google_search"].(map[string]any); !ok {
+ t.Fatalf("expected google_search object, got %#v", got[0])
+ }
+ return
+ }
+ if got[0][tt.wantKey] != tt.wantValue {
+ t.Fatalf("unexpected payload %#v", got[0])
+ }
+ })
+ }
+}
+
+func TestNativeWebFetchToolPayloadsAreProviderSpecific(t *testing.T) {
+ tests := []struct {
+ name string
+ model ai.Model
+ wantKey string
+ wantValue any
+ }{
+ {
+ name: "openai responses unsupported",
+ model: ai.Model{API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenAI},
+ wantKey: "",
+ wantValue: nil,
+ },
+ {
+ name: "openrouter responses",
+ model: ai.Model{API: ai.ApiOpenAIResponses, Provider: ai.ProviderOpenRouter, BuiltInTools: []string{"web_fetch"}},
+ wantKey: "type",
+ wantValue: "openrouter:web_fetch",
+ },
+ {
+ name: "openrouter completions",
+ model: ai.Model{API: ai.ApiOpenAICompletions, Provider: ai.ProviderOpenRouter, BuiltInTools: []string{"web_fetch"}},
+ wantKey: "type",
+ wantValue: "openrouter:web_fetch",
+ },
+ {
+ name: "anthropic",
+ model: ai.Model{API: ai.ApiAnthropicMessages, Provider: ai.ProviderAnthropic, BuiltInTools: []string{"web_fetch"}},
+ wantKey: "type",
+ wantValue: "web_fetch_20250910",
+ },
+ {
+ name: "google",
+ model: ai.Model{API: ai.ApiGoogleGenerativeAI, Provider: ai.ProviderGoogle, BuiltInTools: []string{"web_fetch"}},
+ wantKey: "url_context",
+ wantValue: map[string]any{},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := activeBuiltInToolPayloads(tt.model, RoomConfig{FetchMode: toolModeNative})
+ if tt.wantKey == "" {
+ if len(got) != 0 {
+ t.Fatalf("expected no native fetch payload, got %#v", got)
+ }
+ return
+ }
+ if len(got) != 1 {
+ t.Fatalf("expected one native fetch payload, got %#v", got)
+ }
+ if tt.wantKey == "url_context" {
+ if _, ok := got[0]["url_context"].(map[string]any); !ok {
+ t.Fatalf("expected url_context object, got %#v", got[0])
+ }
+ return
+ }
+ if got[0][tt.wantKey] != tt.wantValue {
+ t.Fatalf("unexpected payload %#v", got[0])
+ }
+ })
+ }
+}
+
func assertToolType(t *testing.T, raw any, toolType string) {
t.Helper()
assertToolTypeCount(t, raw, toolType, 1)
diff --git a/pkg/connector/chat_tools.go b/pkg/connector/chat_tools.go
index 211519c6..96df7e2c 100644
--- a/pkg/connector/chat_tools.go
+++ b/pkg/connector/chat_tools.go
@@ -33,27 +33,31 @@ func (cl *Client) chatTools(msg *bridgev2.MatrixMessage, meta *aiid.PortalMetada
chatTitle = msg.Portal.Name
}
info := chattools.SessionInfo{
- ChatID: chatID,
- ChatTitle: chatTitle,
- ChatFirstMessageAt: chatFirstMessageAt,
- SelectedModel: model.ID,
- SelectedReasoning: cl.reasoningLevelForModel(model, roomConfig),
- DisabledTools: roomConfig.DisabledTools,
- LastKnownTimestamp: formatSessionTimestampUTC(matrixEventTime(nil)),
+ ChatID: chatID,
+ ChatTitle: chatTitle,
+ ChatFirstMessageAt: chatFirstMessageAt,
+ SelectedModel: model.ID,
+ SelectedReasoning: cl.reasoningLevelForModel(model, roomConfig),
+ DisabledTools: roomConfig.DisabledTools,
+ SearchMode: roomSearchMode(roomConfig),
+ FetchMode: roomFetchMode(roomConfig),
+ LastMessageTimestamp: formatSessionTimestampUTC(matrixEventTime(nil)),
+ LastKnownTimezone: cl.lastKnownTimezone(),
}
if msg != nil {
- info.LastKnownTimestamp = formatSessionTimestampUTC(matrixEventTime(msg.Event))
+ info.LastMessageTimestamp = formatSessionTimestampUTC(matrixEventTime(msg.Event))
}
search := cl.searchOptions(roomConfig, provider)
fetch := chattools.FetchOptions{
+ Disabled: roomFetchMode(roomConfig) != toolModeBeeper,
Timeout: time.Duration(cl.Main.Config.Fetch.TimeoutMS) * time.Millisecond,
MaxBytes: cl.Main.Config.Fetch.MaxBytes,
MaxChars: cl.Main.Config.Fetch.MaxChars,
}
if provider.ID == aiid.DefaultProvider && provider.BaseURL != "" {
if token, err := cl.defaultProviderBearerToken(); err == nil {
- if endpoint, err := aiServicesExaContentsURL(provider.BaseURL); err == nil {
- fetch.ExaEndpoint = endpoint
+ if endpoint, err := aiServicesToolURL(provider.BaseURL, "fetch"); err == nil {
+ fetch.ToolEndpoint = endpoint
fetch.APIKey = token
}
}
@@ -81,10 +85,10 @@ func modelSupportsAgentTools(model ai.Model) bool {
return false
}
if model.Compat == nil {
- return true
+ return false
}
supported, ok := model.Compat["tools_supported"].(bool)
- return !ok || supported
+ return ok && supported
}
func modelHasOutputModality(model ai.Model, modality string) bool {
@@ -97,14 +101,14 @@ func modelHasOutputModality(model ai.Model, modality string) bool {
}
func (cl *Client) searchOptions(roomConfig RoomConfig, provider aiid.ProviderConfig) chattools.SearchOptions {
- if toolDisabled(roomConfig.DisabledTools, "web_search") || provider.ID != aiid.DefaultProvider || provider.BaseURL == "" {
+ if roomSearchMode(roomConfig) != toolModeBeeper || provider.ID != aiid.DefaultProvider || provider.BaseURL == "" {
return chattools.SearchOptions{}
}
token, err := cl.defaultProviderBearerToken()
if err != nil {
return chattools.SearchOptions{}
}
- endpoint, err := aiServicesExaSearchURL(provider.BaseURL)
+ endpoint, err := aiServicesToolURL(provider.BaseURL, "web_search")
if err != nil {
return chattools.SearchOptions{}
}
@@ -112,24 +116,16 @@ func (cl *Client) searchOptions(roomConfig RoomConfig, provider aiid.ProviderCon
Enabled: true,
Endpoint: endpoint,
APIKey: token,
- Timeout: 10 * time.Second,
+ Timeout: 30 * time.Second,
}
}
-func aiServicesExaSearchURL(proxyBaseURL string) (string, error) {
- return aiServicesExaURL(proxyBaseURL, "search")
-}
-
-func aiServicesExaContentsURL(proxyBaseURL string) (string, error) {
- return aiServicesExaURL(proxyBaseURL, "contents")
-}
-
-func aiServicesExaURL(proxyBaseURL string, route string) (string, error) {
- parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(proxyBaseURL), "/"))
+func aiServicesToolURL(baseURL string, tool string) (string, error) {
+ parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(baseURL), "/"))
if err != nil {
return "", err
}
- parsed.Path = strings.TrimRight(trimAIProxyProviderPath(parsed.Path), "/") + "/proxy/exa/v1/" + route
+ parsed.Path = strings.TrimRight(parsed.Path, "/") + "/tools/" + tool
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String(), nil
@@ -183,6 +179,33 @@ func (cl *Client) validateReasoningLevel(model ai.Model, roomConfig RoomConfig)
return fmt.Errorf("model %s does not support reasoning level %q", model.ID, level)
}
+func (cl *Client) configuredReasoningMode(model ai.Model, roomConfig RoomConfig) string {
+ mode := strings.ToLower(strings.TrimSpace(roomConfig.ReasoningMode))
+ if mode != "" && mode != "default" {
+ return mode
+ }
+ return strings.ToLower(strings.TrimSpace(string(model.ReasoningMode)))
+}
+
+func (cl *Client) reasoningModeForModel(model ai.Model, roomConfig RoomConfig) string {
+ return cl.configuredReasoningMode(model, roomConfig)
+}
+
+func (cl *Client) validateReasoningMode(model ai.Model, roomConfig RoomConfig) error {
+ mode := strings.ToLower(strings.TrimSpace(roomConfig.ReasoningMode))
+ switch mode {
+ case "", "default":
+ return nil
+ case string(ai.ModelReasoningModeAdaptive):
+ if strings.EqualFold(string(model.ReasoningMode), string(ai.ModelReasoningModeAdaptive)) {
+ return nil
+ }
+ return fmt.Errorf("model %s does not support reasoning mode %q", model.ID, mode)
+ default:
+ return fmt.Errorf("reasoning mode %q is invalid", roomConfig.ReasoningMode)
+ }
+}
+
func clampRoomReasoningLevel(model ai.Model, level ai.ModelThinkingLevel) ai.ModelThinkingLevel {
return ai.ClampThinkingLevel(model, level)
}
diff --git a/pkg/connector/chat_tools_test.go b/pkg/connector/chat_tools_test.go
index 338d8889..79d9e06c 100644
--- a/pkg/connector/chat_tools_test.go
+++ b/pkg/connector/chat_tools_test.go
@@ -28,8 +28,21 @@ func TestChatToolsSkipGoogleVertexImageModels(t *testing.T) {
}
}
-func TestModelSupportsAgentToolsDefaultsToTrue(t *testing.T) {
- if !modelSupportsAgentTools(ai.Model{}) {
- t.Fatal("models without catalog tool metadata should keep default tool behavior")
+func TestModelSupportsAgentToolsRequiresExplicitCatalogSupport(t *testing.T) {
+ if modelSupportsAgentTools(ai.Model{}) {
+ t.Fatal("models without catalog tool metadata should not expose agent tools")
+ }
+ if !modelSupportsAgentTools(ai.Model{Compat: map[string]any{"tools_supported": true}}) {
+ t.Fatal("models with explicit catalog tool support should expose agent tools")
+ }
+}
+
+func TestAIServicesToolURL(t *testing.T) {
+ got, err := aiServicesToolURL("https://ai-services.example/dev", "web_search")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "https://ai-services.example/dev/tools/web_search"; got != want {
+ t.Fatalf("unexpected tool URL %q, want %q", got, want)
}
}
diff --git a/pkg/connector/chatgpt_device_login.go b/pkg/connector/chatgpt_device_login.go
index c0bd3092..cce18058 100644
--- a/pkg/connector/chatgpt_device_login.go
+++ b/pkg/connector/chatgpt_device_login.go
@@ -424,15 +424,6 @@ func chatGPTCodexProvider(credentials chatGPTCredentials) (aiid.ProviderConfig,
}
func defaultChatGPTCodexModel() string {
- for _, modelID := range []string{"gpt-5.5", "gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5-codex"} {
- if _, ok := ai.GetModel(ai.ProviderOpenAICodex, modelID); ok {
- return modelID
- }
- }
- models := ai.GetModels(ai.ProviderOpenAICodex)
- if len(models) > 0 {
- return models[0].ID
- }
return "gpt-5-codex"
}
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index 2d94bd90..b57b2826 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -277,6 +277,7 @@ func (cl *Client) handleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixM
if err := cl.ensureUsablePortal(msg.Portal); err != nil {
return nil, err
}
+ cl.updateLastKnownTimezoneFromMessage(ctx, msg)
if resp, handled, err := cl.handleAISlashCommand(ctx, msg); handled {
return resp, err
}
@@ -302,6 +303,9 @@ func (cl *Client) handleMatrixMessageWithConfig(ctx context.Context, msg *bridge
if err := cl.validateReasoningLevel(model, roomConfig); err != nil {
return nil, err
}
+ if err := cl.validateReasoningMode(model, roomConfig); err != nil {
+ return nil, err
+ }
prompt, err := msgconv.FromMatrix(ctx, cl.Main.Bridge.Matrix.BotIntent(), msg)
if err != nil {
return nil, err
@@ -387,7 +391,7 @@ func (cl *Client) startAsyncPrompt(ctx context.Context, msg *bridgev2.MatrixMess
cl.markPendingFailed(ctx, pending, err)
return
}
- cl.registerProviderBuiltInToolHooks(agentHarness)
+ cl.registerProviderBuiltInToolHooks(agentHarness, roomConfig)
active.harness = agentHarness
active.addPending(pending)
cl.setActiveHarness(msg.Portal.PortalKey, agentHarness)
@@ -1196,6 +1200,7 @@ func (cl *Client) streamPublisherWithEndFrom(publisher bridgev2.BeeperStreamPubl
downstream.End()
return stream
}
+ streamSources := newSourceCollector()
go func() {
defer cancelStream()
if onEnd != nil {
@@ -1248,6 +1253,17 @@ func (cl *Client) streamPublisherWithEndFrom(publisher bridgev2.BeeperStreamPubl
cursor.mu.Lock()
beforeEvents := len(run.Events)
applyAIStreamEvent(writer, evt, model.ContextWindow)
+ if evt.Partial != nil {
+ for _, source := range streamSources.addProviderSources(*evt.Partial) {
+ writer.Custom("com.beeper.source", source)
+ }
+ }
+ if evt.Type == "toolresult" && evt.ToolCall != nil && !hasPriorToolResult(run.Events, beforeEvents, evt.ToolCall.ID) {
+ output := toolOutputEvent{ID: evt.ToolCall.ID, Name: evt.ToolCall.Name, Input: evt.ToolCall.Arguments}
+ for _, source := range streamSources.addToolOutput(output, evt.CustomValue) {
+ writer.Custom("com.beeper.source", source)
+ }
+ }
afterEvents := len(run.Events)
maybeSecondVisibleChunk(evt)
if !seenFirstDelta && isVisibleAIStreamDelta(evt) {
@@ -1655,6 +1671,7 @@ func applyAIStreamEvent(writer *aistream.Writer, evt ai.AssistantMessageEvent, c
case "thinking_delta":
writer.ReasoningDelta(evt.ContentIndex, evt.Delta)
case "thinking_end":
+ writer.ReasoningContentSnapshot(evt.ContentIndex, evt.Content)
writer.ReasoningMessageEnd(evt.ContentIndex)
case "toolcall_start":
if toolCall := toolCallFromEvent(); toolCall != nil {
@@ -1668,6 +1685,14 @@ func applyAIStreamEvent(writer *aistream.Writer, evt ai.AssistantMessageEvent, c
if toolCall := toolCallFromEvent(); toolCall != nil {
writer.ToolInputComplete(toolCall.ID, toolCall.Name, toolCall.Arguments)
}
+ case "toolresult":
+ if toolCall := toolCallFromEvent(); toolCall != nil {
+ result := evt.CustomValue
+ if result == nil {
+ result = map[string]any{"state": agui.ToolResultStateComplete, "status": "success"}
+ }
+ writer.ToolEnd(toolCall.ID, toolCall.Name, toolCall.Arguments, result)
+ }
case "custom":
if evt.CustomName != "" {
writer.Custom(evt.CustomName, evt.CustomValue)
@@ -1696,7 +1721,7 @@ func applyAIStreamEvent(writer *aistream.Writer, evt ai.AssistantMessageEvent, c
}
func writeFinalTextFallback(writer *aistream.Writer, message ai.Message) {
- if writer == nil || writer.Run == nil || writer.Run.Text() != "" || runHasStreamedText(*writer.Run) {
+ if writer == nil || writer.Run == nil || writer.HasTextContent() {
return
}
if text := msgconv.AssistantText(message); text != "" {
@@ -1705,18 +1730,6 @@ func writeFinalTextFallback(writer *aistream.Writer, message ai.Message) {
}
}
-func runHasStreamedText(run aistream.Run) bool {
- for _, evt := range run.Events {
- switch evt.Type() {
- case agui.EventTextMessageContent, agui.EventTextMessageChunk:
- if delta, _ := evt.Get("delta").(string); delta != "" {
- return true
- }
- }
- }
- return false
-}
-
type toolOutputEvent struct {
ID string
Name string
@@ -1725,6 +1738,21 @@ type toolOutputEvent struct {
IsError bool
}
+func hasPriorToolResult(events []agui.Event, before int, toolCallID string) bool {
+ if toolCallID == "" {
+ return false
+ }
+ if before > len(events) {
+ before = len(events)
+ }
+ for _, evt := range events[:before] {
+ if evt.Type() == agui.EventToolCallResult && evt.Get("toolCallId") == toolCallID {
+ return true
+ }
+ }
+ return false
+}
+
func appendToolOutputs(run *aistream.Run, outputs []toolOutputEvent, messages ...ai.Message) {
if run == nil {
return
@@ -1738,6 +1766,7 @@ func appendToolOutputs(run *aistream.Run, outputs []toolOutputEvent, messages ..
}
for _, message := range messages {
sources.addProviderSources(message)
+ sources.addAnswerURLSources(message)
}
for _, source := range sources.sources() {
writer.Custom("com.beeper.source", source)
@@ -2414,6 +2443,7 @@ func (r *activeAIRun) publishToolOutput(ctx context.Context, cl *Client, publish
return fmt.Errorf("no active assistant stream for tool output")
}
stream := r.streams[0]
+ stream.tools = append(stream.tools, output)
r.mu.Unlock()
stream.publish.mu.Lock()
diff --git a/pkg/connector/compaction.go b/pkg/connector/compaction.go
index 8f01370f..1ab5e54e 100644
--- a/pkg/connector/compaction.go
+++ b/pkg/connector/compaction.go
@@ -71,6 +71,9 @@ func (cl *Client) compactPortalSession(ctx context.Context, portal *bridgev2.Por
if err := cl.validateReasoningLevel(model, roomConfig); err != nil {
return harness.CompactResult{}, err
}
+ if err := cl.validateReasoningMode(model, roomConfig); err != nil {
+ return harness.CompactResult{}, err
+ }
agentHarness, err := harness.NewAgentHarness(harness.AgentHarnessOptions{
Session: agentSession,
Model: model,
diff --git a/pkg/connector/config.go b/pkg/connector/config.go
index 2daad645..7a993238 100644
--- a/pkg/connector/config.go
+++ b/pkg/connector/config.go
@@ -13,7 +13,6 @@ import (
//go:embed example-config.yaml
var ExampleConfig string
-const defaultAIServicesProxyPath = "/proxy/openai/v1"
const defaultBeeperAIModel = "beeper/default"
const defaultTitleGenerationModel = "gpt-4.1-mini"
const fallbackTitleGenerationModel = "gpt-5-mini"
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 0c34914c..97207c1d 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -87,7 +87,7 @@ func (c *Connector) GetBridgeInfoVersion() (info, capabilities int) {
}
func (c *Connector) defaultProviderConfig(userMXID id.UserID) aiid.ProviderConfig {
- baseURL := c.defaultAIServicesOpenAIProxyBaseURL(userMXID)
+ baseURL := c.defaultAIServicesBaseURL(userMXID)
return aiid.ProviderConfig{
ID: aiid.DefaultProvider,
DisplayName: "Beeper AI",
@@ -98,32 +98,32 @@ func (c *Connector) defaultProviderConfig(userMXID id.UserID) aiid.ProviderConfi
}
}
-func (c *Connector) defaultAIServicesOpenAIProxyBaseURL(userMXID id.UserID) string {
+func (c *Connector) defaultAIServicesBaseURL(userMXID id.UserID) string {
userDomain := userMXID.Homeserver()
bridgeHost := c.homeserverAddressHost()
if bridgeHost == "megahungry-proxy.megahungry" {
if userDomain == "beeper.localtest.me" {
- return "http://ai-services.beeper" + defaultAIServicesProxyPath
+ return "http://ai-services.beeper"
}
if isBeeperAIServiceDomain(userDomain) {
- return "https://ai-services." + userDomain + defaultAIServicesProxyPath
+ return "https://ai-services." + userDomain
}
if userDomain != "" {
return ""
}
- return "http://ai-services.beeper" + defaultAIServicesProxyPath
+ return "http://ai-services.beeper"
}
domain := homeserverServiceDomain(bridgeHost)
if domain != "" {
if userDomain != "" && userDomain != domain {
return ""
}
- return "https://ai-services." + domain + defaultAIServicesProxyPath
+ return "https://ai-services." + domain
}
if userDomain == "" {
return ""
}
- return "https://ai-services." + userDomain + defaultAIServicesProxyPath
+ return "https://ai-services." + userDomain
}
func isBeeperAIServiceDomain(domain string) bool {
diff --git a/pkg/connector/contacts.go b/pkg/connector/contacts.go
index 8232e4d6..b981a89c 100644
--- a/pkg/connector/contacts.go
+++ b/pkg/connector/contacts.go
@@ -85,7 +85,8 @@ func (cl *Client) createModelChat(ctx context.Context, provider aiid.ProviderCon
return nil, err
}
reasoning := cl.reasoningLevelForModel(model, roomConfig)
- if _, err = cl.applyRoomModelState(ctx, portal, provider, model, canonicalModel, reasoning, applyRoomModelStateOptions{ForceAvatar: created}); err != nil {
+ reasoningMode := cl.reasoningModeForModel(model, roomConfig)
+ if _, err = cl.applyRoomModelState(ctx, portal, provider, model, canonicalModel, reasoning, reasoningMode, applyRoomModelStateOptions{ForceAvatar: created}); err != nil {
return nil, err
}
cl.refreshRoomCapabilities(ctx, portal)
@@ -167,22 +168,6 @@ func (cl *Client) modelContacts(ctx context.Context, query string) []*bridgev2.R
seen[contact.UserID] = true
filtered = append(filtered, contact)
}
- providers := cl.providers()
- for _, provider := range providers {
- if !providerAllowsArbitraryModels(provider) {
- continue
- }
- model, ok := arbitraryModelForProvider(provider, query)
- if !ok {
- continue
- }
- userID := aiid.ModelContactID(provider.ID, model.ID)
- if seen[userID] {
- continue
- }
- seen[userID] = true
- filtered = append(filtered, modelContactWithGhost(ctx, cl.bridge(), provider, model))
- }
return filtered
}
@@ -308,9 +293,6 @@ func (cl *Client) providerWithCatalogModels(ctx context.Context, provider aiid.P
}
func (cl *Client) providerForModelContacts(ctx context.Context, provider aiid.ProviderConfig, refresh bool) (aiid.ProviderConfig, bool) {
- if provider.ID != aiid.DefaultProvider {
- return provider, true
- }
refreshed, err := cl.providerWithCatalogModelsStrictWithRefresh(ctx, provider, refresh)
if err != nil {
zerolog.Ctx(ctx).Warn().
@@ -335,18 +317,12 @@ func (cl *Client) providerWithCatalogModelsStrict(ctx context.Context, provider
}
func (cl *Client) providerWithCatalogModelsStrictWithRefresh(ctx context.Context, provider aiid.ProviderConfig, refresh bool) (aiid.ProviderConfig, error) {
- if provider.ID != aiid.DefaultProvider {
- return provider, nil
- }
- if len(provider.Models) > 0 && !refresh {
- return provider, nil
- }
models, err := cl.cachedAIServicesCatalogModels(ctx, provider, refresh)
if err != nil {
return provider, err
}
if len(models) > 0 {
- provider.Models = models
+ provider.Models = mergeProviderCatalogModels(provider, models)
zerolog.Ctx(ctx).Debug().
Str("action", "ai_model_catalog").
Str("provider_id", provider.ID).
@@ -356,6 +332,58 @@ func (cl *Client) providerWithCatalogModelsStrictWithRefresh(ctx context.Context
return provider, nil
}
+func mergeProviderCatalogModels(provider aiid.ProviderConfig, catalog []ai.Model) []ai.Model {
+ if provider.ID == aiid.DefaultProvider || len(provider.Models) == 0 {
+ return cloneModels(catalog)
+ }
+ byID := make(map[string]ai.Model, len(catalog))
+ for _, model := range catalog {
+ byID[model.ID] = model
+ }
+ models := make([]ai.Model, 0, len(provider.Models))
+ for _, configured := range provider.Models {
+ model := normalizeProviderModel(configured, provider)
+ if catalogModel, ok := byID[model.ID]; ok {
+ model = mergeProviderCatalogModel(model, catalogModel)
+ }
+ models = append(models, model)
+ }
+ return models
+}
+
+func mergeProviderCatalogModel(configured ai.Model, catalog ai.Model) ai.Model {
+ model := catalog
+ if configured.Name != "" && configured.Name != configured.ID {
+ model.Name = configured.Name
+ }
+ if len(configured.Input) > 0 {
+ model.Input = slices.Clone(configured.Input)
+ }
+ if len(configured.Output) > 0 {
+ model.Output = slices.Clone(configured.Output)
+ }
+ if len(configured.BuiltInTools) > 0 {
+ model.BuiltInTools = slices.Clone(configured.BuiltInTools)
+ }
+ if configured.ContextWindow != 0 {
+ model.ContextWindow = configured.ContextWindow
+ }
+ if configured.MaxTokens != 0 {
+ model.MaxTokens = configured.MaxTokens
+ }
+ if configured.Headers != nil {
+ model.Headers = maps.Clone(configured.Headers)
+ }
+ if configured.Compat != nil {
+ compat := maps.Clone(model.Compat)
+ for key, value := range configured.Compat {
+ compat[key] = value
+ }
+ model.Compat = compat
+ }
+ return model
+}
+
func (cl *Client) cachedAIServicesCatalogModels(ctx context.Context, provider aiid.ProviderConfig, refresh bool) ([]ai.Model, error) {
providerKey := providerCatalogCacheKey(provider)
cl.catalogCacheMu.Lock()
@@ -401,10 +429,17 @@ func listedProviderModelContacts(ctx context.Context, br *bridgev2.Bridge, provi
}
func (cl *Client) aiServicesCatalogModels(ctx context.Context, provider aiid.ProviderConfig) ([]ai.Model, error) {
- if cl == nil || cl.Main == nil || provider.ID != aiid.DefaultProvider || provider.BaseURL == "" || cl.Main.AppServiceToken == "" {
+ if cl == nil || cl.Main == nil || cl.UserLogin == nil || cl.Main.AppServiceToken == "" {
return nil, nil
}
- modelsURL, err := aiServicesModelsURL(provider.BaseURL)
+ catalogProvider := provider
+ if provider.ID != aiid.DefaultProvider {
+ catalogProvider = cl.Main.defaultProviderConfig(cl.UserLogin.UserMXID)
+ if catalogProvider.BaseURL == "" {
+ return nil, fmt.Errorf("AI Services is not available for %s", cl.UserLogin.UserMXID.Homeserver())
+ }
+ }
+ modelsURL, err := aiServicesModelsURL(catalogProvider.BaseURL)
if err != nil {
return nil, err
}
@@ -432,6 +467,9 @@ func (cl *Client) aiServicesCatalogModels(ctx context.Context, provider aiid.Pro
}
models := make([]ai.Model, 0, len(body.Data))
for _, item := range body.Data {
+ if provider.ID != aiid.DefaultProvider && !item.matchesProvider(provider.Provider) {
+ continue
+ }
modelID := strings.TrimSpace(item.ID)
if modelID == "" {
continue
@@ -445,6 +483,7 @@ func (cl *Client) aiServicesCatalogModels(ctx context.Context, provider aiid.Pro
Reasoning: item.reasoning(),
ThinkingLevelMap: item.thinkingLevelMap(),
DefaultThinkingLevel: item.defaultThinkingLevel(),
+ ReasoningMode: item.reasoningMode(),
Input: item.inputModalities(),
Output: item.outputModalities(),
ContextWindow: item.contextWindow(),
@@ -452,44 +491,23 @@ func (cl *Client) aiServicesCatalogModels(ctx context.Context, provider aiid.Pro
BuiltInTools: item.builtInTools(),
Compat: item.compat(),
}
- model = item.applyProviderRoute(model, provider)
+ model = item.applyRuntime(model, provider, provider.ID == aiid.DefaultProvider)
models = append(models, normalizeProviderModel(model, provider))
}
return models, nil
}
-func aiServicesModelsURL(proxyBaseURL string) (string, error) {
- parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(proxyBaseURL), "/"))
+func aiServicesModelsURL(baseURL string) (string, error) {
+ parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(baseURL), "/"))
if err != nil {
return "", err
}
- parsed.Path = trimAIProxyProviderPath(parsed.Path)
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/models"
- parsed.RawQuery = url.Values{"feature": {"bridge:ai"}, "route": {"responses"}}.Encode()
+ parsed.RawQuery = url.Values{"feature": {"bridge:ai"}}.Encode()
parsed.Fragment = ""
return parsed.String(), nil
}
-func trimAIProxyProviderPath(path string) string {
- for _, suffix := range []string{
- "/proxy/openai/v1",
- "/proxy/openai",
- "/proxy/openrouter/v1",
- "/proxy/openrouter",
- "/proxy/anthropic/v1",
- "/proxy/anthropic",
- "/proxy/vertex/v1",
- "/proxy/vertex",
- "/proxy/a8c/v1",
- "/proxy/a8c",
- "/proxy/_/v1",
- "/proxy/_",
- } {
- path = strings.TrimSuffix(path, suffix)
- }
- return path
-}
-
type aiServicesModelListResponse struct {
Type string `json:"type"`
Data []aiServicesModelEntry `json:"data"`
@@ -509,11 +527,14 @@ type aiServicesModelEntry struct {
TopProvider *struct {
MaxCompletionTokens int `json:"max_completion_tokens"`
} `json:"top_provider"`
- Provider *struct {
- ID string `json:"id"`
- ModelID string `json:"model_id"`
- API string `json:"api"`
- } `json:"provider"`
+ Runtime *struct {
+ API string `json:"api"`
+ Provider string `json:"provider"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ Endpoint string `json:"endpoint"`
+ Compat *aiServicesModelCompat `json:"compat"`
+ } `json:"runtime"`
Capabilities *struct {
Input struct {
Modalities []string `json:"modalities"`
@@ -526,6 +547,7 @@ type aiServicesModelEntry struct {
Levels []string `json:"levels"`
LevelMap map[string]*string `json:"level_map"`
DefaultLevel string `json:"default_level"`
+ Mode string `json:"mode"`
} `json:"reasoning"`
Tools *struct {
Supported bool `json:"supported"`
@@ -538,55 +560,100 @@ type aiServicesModelEntry struct {
} `json:"capabilities"`
}
-func (entry aiServicesModelEntry) applyProviderRoute(model ai.Model, provider aiid.ProviderConfig) ai.Model {
- if entry.Provider == nil || entry.Provider.ID == "" {
+type aiServicesModelCompat struct {
+ SupportsStore *bool `json:"supports_store,omitempty"`
+ SupportsDeveloperRole *bool `json:"supports_developer_role,omitempty"`
+ SupportsReasoningEffort *bool `json:"supports_reasoning_effort,omitempty"`
+ SupportsUsageInStreaming *bool `json:"supports_usage_in_streaming,omitempty"`
+ MaxTokensField string `json:"max_tokens_field,omitempty"`
+ RequiresToolResultName *bool `json:"requires_tool_result_name,omitempty"`
+ RequiresAssistantAfterToolResult *bool `json:"requires_assistant_after_tool_result,omitempty"`
+ RequiresThinkingAsText *bool `json:"requires_thinking_as_text,omitempty"`
+ RequiresReasoningContentOnAssistantMessages *bool `json:"requires_reasoning_content_on_assistant_messages,omitempty"`
+ ThinkingFormat string `json:"thinking_format,omitempty"`
+ ZaiToolStream *bool `json:"zai_tool_stream,omitempty"`
+ SupportsStrictMode *bool `json:"supports_strict_mode,omitempty"`
+ CacheControlFormat string `json:"cache_control_format,omitempty"`
+ SendSessionAffinityHeaders *bool `json:"send_session_affinity_headers,omitempty"`
+ SupportsLongCacheRetention *bool `json:"supports_long_cache_retention,omitempty"`
+ SendSessionIDHeader *bool `json:"send_session_id_header,omitempty"`
+ SupportsEagerToolInputStreaming *bool `json:"supports_eager_tool_input_streaming,omitempty"`
+ SupportsCacheControlOnTools *bool `json:"supports_cache_control_on_tools,omitempty"`
+ SupportsTemperature *bool `json:"supports_temperature,omitempty"`
+ ForceAdaptiveThinking *bool `json:"force_adaptive_thinking,omitempty"`
+ AllowEmptySignature *bool `json:"allow_empty_signature,omitempty"`
+}
+
+func (entry aiServicesModelEntry) matchesProvider(provider ai.Provider) bool {
+ if entry.Runtime == nil || entry.Runtime.Provider == "" {
+ return provider == ""
+ }
+ return ai.Provider(entry.Runtime.Provider) == provider
+}
+
+func (entry aiServicesModelEntry) applyRuntime(model ai.Model, provider aiid.ProviderConfig, useRuntimeBaseURL bool) ai.Model {
+ if entry.Runtime == nil {
return model
}
if model.Compat == nil {
model.Compat = map[string]any{}
}
- model.Compat["provider_id"] = entry.Provider.ID
- model.Compat["provider_model_id"] = entry.Provider.ModelID
- switch entry.Provider.ID {
- case "wpcom_anthropic":
- model.API = ai.ApiAnthropicMessages
- model.Provider = ai.ProviderAnthropic
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "anthropic", false)
- case "wpcom_vertex":
- model.API = ai.ApiGoogleVertex
- model.Provider = ai.ProviderGoogleVertex
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "vertex", false)
- case "wpcom_openai":
- model.API = ai.ApiOpenAIResponses
- model.Provider = ai.ProviderOpenAI
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "openai", true)
- case "wpcom_google":
- model.API = ai.ApiGoogleVertex
- model.Provider = ai.ProviderGoogleVertex
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "vertex", false)
- case "wpcom_xai":
- model.API = ai.ApiOpenAIResponses
- model.Provider = ai.ProviderXAI
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "xai", true)
- case "wpcom_groq":
- model.API = ai.ApiOpenAIResponses
- model.Provider = ai.ProviderGroq
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "groq", true)
- case "wpcom_a8c":
- model.API = ai.ApiOpenAICompletions
- if entry.Provider.API == string(ai.ApiOpenAIResponses) {
- model.API = ai.ApiOpenAIResponses
- }
- model.Provider = ai.Provider("a8c")
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "a8c", true)
- case "openrouter":
- model.API = ai.ApiOpenAIResponses
- model.Provider = ai.ProviderOpenRouter
- model.BaseURL = aiServicesProxyBaseURL(provider.BaseURL, "openrouter", true)
+ if entry.Runtime.Model != "" {
+ model.Compat["runtime_model"] = entry.Runtime.Model
+ }
+ if entry.Runtime.API != "" {
+ model.API = ai.Api(entry.Runtime.API)
}
+ if entry.Runtime.Provider != "" {
+ model.Provider = ai.Provider(entry.Runtime.Provider)
+ model.Compat["runtime_provider"] = entry.Runtime.Provider
+ }
+ if useRuntimeBaseURL && entry.Runtime.BaseURL != "" {
+ model.BaseURL = aiServicesRuntimeBaseURL(provider.BaseURL, entry.Runtime.BaseURL)
+ }
+ entry.Runtime.Compat.applyTo(model.Compat)
return model
}
+func (compat *aiServicesModelCompat) applyTo(dst map[string]any) {
+ if compat == nil {
+ return
+ }
+ setBoolCompat(dst, "supportsStore", compat.SupportsStore)
+ setBoolCompat(dst, "supportsDeveloperRole", compat.SupportsDeveloperRole)
+ setBoolCompat(dst, "supportsReasoningEffort", compat.SupportsReasoningEffort)
+ setBoolCompat(dst, "supportsUsageInStreaming", compat.SupportsUsageInStreaming)
+ setStringCompat(dst, "maxTokensField", compat.MaxTokensField)
+ setBoolCompat(dst, "requiresToolResultName", compat.RequiresToolResultName)
+ setBoolCompat(dst, "requiresAssistantAfterToolResult", compat.RequiresAssistantAfterToolResult)
+ setBoolCompat(dst, "requiresThinkingAsText", compat.RequiresThinkingAsText)
+ setBoolCompat(dst, "requiresReasoningContentOnAssistantMessages", compat.RequiresReasoningContentOnAssistantMessages)
+ setStringCompat(dst, "thinkingFormat", compat.ThinkingFormat)
+ setBoolCompat(dst, "zaiToolStream", compat.ZaiToolStream)
+ setBoolCompat(dst, "supportsStrictMode", compat.SupportsStrictMode)
+ setStringCompat(dst, "cacheControlFormat", compat.CacheControlFormat)
+ setBoolCompat(dst, "sendSessionAffinityHeaders", compat.SendSessionAffinityHeaders)
+ setBoolCompat(dst, "supportsLongCacheRetention", compat.SupportsLongCacheRetention)
+ setBoolCompat(dst, "sendSessionIdHeader", compat.SendSessionIDHeader)
+ setBoolCompat(dst, "supportsEagerToolInputStreaming", compat.SupportsEagerToolInputStreaming)
+ setBoolCompat(dst, "supportsCacheControlOnTools", compat.SupportsCacheControlOnTools)
+ setBoolCompat(dst, "supportsTemperature", compat.SupportsTemperature)
+ setBoolCompat(dst, "forceAdaptiveThinking", compat.ForceAdaptiveThinking)
+ setBoolCompat(dst, "allowEmptySignature", compat.AllowEmptySignature)
+}
+
+func setBoolCompat(dst map[string]any, key string, value *bool) {
+ if value != nil {
+ dst[key] = *value
+ }
+}
+
+func setStringCompat(dst map[string]any, key string, value string) {
+ if value != "" {
+ dst[key] = value
+ }
+}
+
func (entry aiServicesModelEntry) compat() map[string]any {
compat := map[string]any{}
if entry.Metadata != nil {
@@ -606,15 +673,12 @@ func (entry aiServicesModelEntry) compat() map[string]any {
return compat
}
-func aiServicesProxyBaseURL(baseURL string, providerPath string, includeV1 bool) string {
+func aiServicesRuntimeBaseURL(baseURL string, runtimeBaseURL string) string {
parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(baseURL), "/"))
if err != nil {
return baseURL
}
- parsed.Path = strings.TrimRight(trimAIProxyProviderPath(parsed.Path), "/") + "/proxy/" + providerPath
- if includeV1 {
- parsed.Path += "/v1"
- }
+ parsed.Path = joinURLPath(parsed.Path, runtimeBaseURL)
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
@@ -658,6 +722,13 @@ func (entry aiServicesModelEntry) reasoning() bool {
return entry.Capabilities != nil && entry.Capabilities.Reasoning != nil && entry.Capabilities.Reasoning.Supported
}
+func (entry aiServicesModelEntry) reasoningMode() ai.ModelReasoningMode {
+ if entry.Capabilities == nil || entry.Capabilities.Reasoning == nil {
+ return ""
+ }
+ return ai.ModelReasoningMode(strings.ToLower(strings.TrimSpace(entry.Capabilities.Reasoning.Mode)))
+}
+
func (entry aiServicesModelEntry) builtInTools() []string {
if entry.Capabilities == nil || entry.Capabilities.Tools == nil || !entry.Capabilities.Tools.Supported {
return nil
@@ -794,16 +865,6 @@ func resolveModelForProvider(provider aiid.ProviderConfig, identifier string) (a
return model, true
}
}
- if modelID, ok := strings.CutPrefix(identifier, provider.ID+"/"); ok {
- if model, ok := arbitraryModelForProvider(provider, modelID); ok && providerAllowsArbitraryModels(provider) {
- return model, true
- }
- }
- if !strings.Contains(identifier, "/") && providerAllowsArbitraryModels(provider) {
- if model, ok := arbitraryModelForProvider(provider, identifier); ok {
- return model, true
- }
- }
return ai.Model{}, false
}
@@ -857,30 +918,6 @@ func contactModels(provider aiid.ProviderConfig) []ai.Model {
return []ai.Model{normalizeProviderModel(modelForProviderConfig(provider, provider.DefaultModel), provider)}
}
-func arbitraryModelForProvider(provider aiid.ProviderConfig, query string) (ai.Model, bool) {
- modelID := strings.TrimSpace(query)
- if modelID == "" {
- return ai.Model{}, false
- }
- if stripped, ok := strings.CutPrefix(modelID, provider.ID+"/"); ok {
- modelID = strings.TrimSpace(stripped)
- }
- if modelID == "" {
- return ai.Model{}, false
- }
- model := normalizeProviderModel(modelForProviderConfig(provider, modelID), provider)
- displayName := provider.DisplayName
- if displayName == "" {
- displayName = providerDisplayName(provider.ID)
- }
- model.Name = displayName + ": " + modelID
- return model, true
-}
-
-func providerAllowsArbitraryModels(provider aiid.ProviderConfig) bool {
- return provider.ID != aiid.DefaultProvider
-}
-
func cloneModelContacts(contacts []*bridgev2.ResolveIdentifierResponse) []*bridgev2.ResolveIdentifierResponse {
if contacts == nil {
return nil
diff --git a/pkg/connector/contacts_test.go b/pkg/connector/contacts_test.go
index fc6e2d25..bcb5d0fa 100644
--- a/pkg/connector/contacts_test.go
+++ b/pkg/connector/contacts_test.go
@@ -58,7 +58,7 @@ func TestModelContactsCacheRefreshesAfterCachedRead(t *testing.T) {
}))
defer server.Close()
- client := newDefaultProviderContactClient(server.URL + "/proxy/openai/v1")
+ client := newDefaultProviderContactClient(server.URL)
contacts, err := client.GetContactList(context.Background())
if err != nil {
t.Fatal(err)
@@ -91,7 +91,7 @@ func TestConnectWarmsModelContactsCache(t *testing.T) {
}))
defer server.Close()
- client := newDefaultProviderContactClient(server.URL + "/proxy/openai/v1")
+ client := newDefaultProviderContactClient(server.URL)
client.Connect(context.Background())
deadline := time.Now().Add(time.Second)
for {
@@ -166,11 +166,11 @@ func TestAIServicesCatalogModelsFetchesVisibleModels(t *testing.T) {
if r.URL.Path != "/models" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
- if r.URL.Query().Get("feature") != "bridge:ai" || r.URL.Query().Get("route") != "responses" {
+ if r.URL.Query().Get("feature") != "bridge:ai" || r.URL.Query().Has("route") {
t.Fatalf("unexpected query %s", r.URL.RawQuery)
}
gotAuth = r.Header.Get("Authorization")
- _, _ = w.Write([]byte(`{"type":"com.beeper.ai.model_list","data":[{"id":"openai/gpt-5.5","name":"GPT-5.5","capabilities":{"input":{"modalities":["text","image"]},"output":{"modalities":["text"]},"reasoning":{"supported":true,"levels":["off","minimal","low","medium","high","xhigh"],"level_map":{"xhigh":"xhigh"},"default_level":"off"},"tools":{"supported":true,"built_in":["image_generation"]},"limits":{"context_tokens":1050000,"output_tokens":128000}}},{"id":"minimax/minimax-m2.7","name":"MiniMax M2.7","provider":{"id":"openrouter","model_id":"minimax/minimax-m2.7","api":"openai-responses"},"capabilities":{"input":{"modalities":["text"]},"output":{"modalities":["text"]},"reasoning":{"supported":true,"levels":["low","medium","high"],"level_map":{"off":null,"minimal":null},"default_level":"low"}}},{"id":"beeper/fast","name":"Beeper Fast","capabilities":{"input":{"modalities":["text"]},"output":{"modalities":["text"]}}}]}`))
+ _, _ = w.Write([]byte(`{"type":"com.beeper.ai.model_list","data":[{"id":"openai/gpt-5.5","name":"GPT-5.5","capabilities":{"input":{"modalities":["text","image"]},"output":{"modalities":["text"]},"reasoning":{"supported":true,"levels":["off","minimal","low","medium","high","xhigh"],"level_map":{"xhigh":"xhigh"},"default_level":"off","mode":"adaptive"},"tools":{"supported":true,"built_in":["image_generation"]},"limits":{"context_tokens":1050000,"output_tokens":128000}}},{"id":"minimax/minimax-m2.7","name":"MiniMax M2.7","runtime":{"provider":"openrouter","model":"minimax/minimax-m2.7","api":"openai-completions","base_url":"/proxy/openrouter/v1","compat":{"supports_developer_role":false,"supports_reasoning_effort":true,"max_tokens_field":"max_completion_tokens","thinking_format":"openrouter"}},"capabilities":{"input":{"modalities":["text"]},"output":{"modalities":["text"]},"reasoning":{"supported":true,"levels":["low","medium","high"],"level_map":{"off":null,"minimal":null},"default_level":"low"}}},{"id":"beeper/fast","name":"Beeper Fast","capabilities":{"input":{"modalities":["text"]},"output":{"modalities":["text"]}}}]}`))
}))
defer server.Close()
@@ -186,7 +186,7 @@ func TestAIServicesCatalogModelsFetchesVisibleModels(t *testing.T) {
ID: aiid.DefaultProvider,
Provider: ai.ProviderOpenAI,
API: ai.ApiOpenAIResponses,
- BaseURL: server.URL + "/proxy/openai/v1",
+ BaseURL: server.URL,
})
if err != nil {
t.Fatal(err)
@@ -194,7 +194,7 @@ func TestAIServicesCatalogModelsFetchesVisibleModels(t *testing.T) {
if !strings.HasPrefix(gotAuth, "Bearer "+aiServicesAppserviceTokenPrefix) {
t.Fatalf("unexpected auth header %q", gotAuth)
}
- if len(models) != 3 || models[0].ID != "openai/gpt-5.5" || models[0].BaseURL != server.URL+"/proxy/openai/v1" {
+ if len(models) != 3 || models[0].ID != "openai/gpt-5.5" || models[0].BaseURL != server.URL {
t.Fatalf("unexpected models %#v", models)
}
if models[0].ContextWindow != 1050000 || models[0].MaxTokens != 128000 {
@@ -212,20 +212,71 @@ func TestAIServicesCatalogModelsFetchesVisibleModels(t *testing.T) {
if got := models[0].ThinkingLevelMap[ai.ModelThinkingLevelXHigh]; got == nil || *got != "xhigh" {
t.Fatalf("expected AI Services xhigh map, got %#v", models[0].ThinkingLevelMap)
}
+ if models[0].ReasoningMode != "adaptive" {
+ t.Fatalf("expected AI Services reasoning mode, got %#v", models[0].ReasoningMode)
+ }
if len(models[0].BuiltInTools) != 1 || models[0].BuiltInTools[0] != "image_generation" {
t.Fatalf("expected AI Services built-in tools, got %#v", models[0].BuiltInTools)
}
if supported, ok := models[0].Compat["tools_supported"].(bool); !ok || !supported {
t.Fatalf("expected AI Services tool support metadata, got %#v", models[0].Compat)
}
+ if modelSupportsAgentTools(models[1]) {
+ t.Fatalf("expected missing AI Services tools capability to disable agent tools, got %#v", models[1].Compat)
+ }
if models[1].DefaultThinkingLevel != ai.ModelThinkingLevelLow || roomThinkingLevelSupported(models[1], ai.ModelThinkingLevelOff) {
t.Fatalf("expected MiniMax reasoning to default to low and reject off, got %#v", models[1])
}
+ if models[1].API != ai.ApiOpenAICompletions || models[1].Provider != ai.ProviderOpenRouter || models[1].BaseURL != server.URL+"/proxy/openrouter/v1" {
+ t.Fatalf("expected MiniMax OpenRouter route, got %#v", models[1])
+ }
+ if models[1].Compat["supportsDeveloperRole"] != false || models[1].Compat["thinkingFormat"] != "openrouter" || models[1].Compat["maxTokensField"] != "max_completion_tokens" {
+ t.Fatalf("expected MiniMax OpenRouter compat from AI Services, got %#v", models[1].Compat)
+ }
if roomThinkingLevelSupported(models[1], ai.ModelThinkingLevelMinimal) {
t.Fatalf("expected MiniMax reasoning to reject minimal, got %#v", models[1])
}
}
+func TestResolveCanonicalRoomModelUsesAIServicesCatalogBeforeValidation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/models" {
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"type":"com.beeper.ai.model_list","data":[{"id":"openai/gpt-5.4","name":"GPT-5.4","runtime":{"provider":"openai","model":"gpt-5.4","api":"openai-responses","base_url":"/proxy/openai/v1"},"capabilities":{"input":{"modalities":["text"]},"output":{"modalities":["text"]}}}]}`))
+ }))
+ defer server.Close()
+
+ provider := aiid.ProviderConfig{
+ ID: aiid.DefaultProvider,
+ Provider: ai.ProviderOpenAI,
+ API: ai.ApiOpenAIResponses,
+ BaseURL: server.URL,
+ DefaultModel: "beeper/default",
+ Models: []ai.Model{{ID: "beeper/default", Provider: ai.ProviderOpenAI, API: ai.ApiOpenAIResponses}},
+ }
+ client := &Client{
+ Main: &Connector{
+ AppServiceToken: "as-token",
+ },
+ UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{
+ ID: "login",
+ UserMXID: "@test:beeper.localtest.me",
+ Metadata: &aiid.UserLoginMetadata{Providers: map[string]aiid.ProviderConfig{
+ provider.ID: provider,
+ }},
+ }},
+ }
+
+ _, model, canonical, err := client.resolveCanonicalRoomModel(context.Background(), RoomConfig{ProviderID: aiid.DefaultProvider, ModelID: "openai/gpt-5.4"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if model.ID != "openai/gpt-5.4" || model.BaseURL != server.URL+"/proxy/openai/v1" || canonical != "beeper/openai/gpt-5.4" {
+ t.Fatalf("unexpected resolved model canonical=%q model=%#v", canonical, model)
+ }
+}
+
func TestAIServicesModelEntryReasoningDefaultsCanRequireReasoning(t *testing.T) {
var entry aiServicesModelEntry
if err := json.Unmarshal([]byte(`{"id":"deepseek/deepseek-r1-0528","name":"DeepSeek R1 (0528)","capabilities":{"input":{"modalities":["text"]},"output":{"modalities":["text"]},"reasoning":{"supported":true,"levels":["minimal","low","medium","high"],"level_map":{"off":null},"default_level":"minimal"}}}`), &entry); err != nil {
@@ -273,7 +324,7 @@ func TestAIServicesCatalogModelsDoNotUseBridgeCatalogWhenMetadataMissing(t *test
ID: aiid.DefaultProvider,
Provider: ai.ProviderOpenAI,
API: ai.ApiOpenAIResponses,
- BaseURL: server.URL + "/proxy/openai/v1",
+ BaseURL: server.URL,
})
if err != nil {
t.Fatal(err)
@@ -292,16 +343,12 @@ func TestAIServicesCatalogModelsDoNotUseBridgeCatalogWhenMetadataMissing(t *test
}
}
-func TestAIServicesModelsURLStripsProviderProxyPaths(t *testing.T) {
+func TestAIServicesModelsURLUsesBaseURL(t *testing.T) {
tests := map[string]string{
- "https://ai-services.beeper.com/proxy/openai/v1": "https://ai-services.beeper.com/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/proxy/openrouter/v1": "https://ai-services.beeper.com/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/proxy/anthropic": "https://ai-services.beeper.com/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/proxy/vertex": "https://ai-services.beeper.com/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/proxy/a8c/v1": "https://ai-services.beeper.com/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/proxy/_/v1/responses": "https://ai-services.beeper.com/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/dev/proxy/openai/v1": "https://ai-services.beeper.com/dev/models?feature=bridge%3Aai&route=responses",
- "https://ai-services.beeper.com/dev/proxy/openrouter/v1/": "https://ai-services.beeper.com/dev/models?feature=bridge%3Aai&route=responses",
+ "https://ai-services.beeper.com": "https://ai-services.beeper.com/models?feature=bridge%3Aai",
+ "https://ai-services.beeper.com/": "https://ai-services.beeper.com/models?feature=bridge%3Aai",
+ "https://ai-services.beeper.com/dev": "https://ai-services.beeper.com/dev/models?feature=bridge%3Aai",
+ "https://ai-services.beeper.com/dev/": "https://ai-services.beeper.com/dev/models?feature=bridge%3Aai",
}
for input, want := range tests {
got, err := aiServicesModelsURL(input)
@@ -317,14 +364,14 @@ func TestAIServicesModelsURLStripsProviderProxyPaths(t *testing.T) {
func TestAIServicesCatalogModelsUsesPublishedProviderRoutes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"data":[
- {"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","provider":{"id":"wpcom_anthropic","model_id":"claude-sonnet-4-5","api":"openai-responses"}},
- {"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash Lite","provider":{"id":"wpcom_vertex","model_id":"gemini-2.5-flash-lite","api":"openai-responses"}},
- {"id":"google/gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","provider":{"id":"wpcom_vertex","model_id":"gemini-3.1-pro-preview","api":"openai-responses"}},
- {"id":"google/gemini-2.5-flash","name":"Gemini 2.5 Flash","provider":{"id":"wpcom_google","model_id":"gemini-2.5-flash","api":"openai-responses"}},
- {"id":"x-ai/grok-4.20","name":"Grok 4.20","provider":{"id":"wpcom_xai","model_id":"x-ai/grok-4.20","api":"openai-responses"}},
- {"id":"groq/qwen/qwen3-32b","name":"Qwen 3 32B","provider":{"id":"wpcom_groq","model_id":"groq/qwen/qwen3-32b","api":"openai-responses"}},
- {"id":"openai/gpt-oss-120b","name":"GPT OSS 120B","provider":{"id":"wpcom_a8c","model_id":"gpt-oss-120b","api":"openai-completions"}},
- {"id":"anthropic/claude-sonnet-4.5","name":"Claude via OpenRouter","metadata":{"family":"claude","provider_logo_url":"/models/providers/anthropic.png"},"provider":{"id":"openrouter","model_id":"anthropic/claude-sonnet-4.5","api":"openai-responses"}}
+ {"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","runtime":{"provider":"anthropic","model":"claude-sonnet-4-5","api":"anthropic-messages","base_url":"/proxy/anthropic"}},
+ {"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash Lite","runtime":{"provider":"google-vertex","model":"gemini-2.5-flash-lite","api":"google-vertex","base_url":"/proxy/vertex"}},
+ {"id":"google/gemini-3.1-pro-preview","name":"Gemini 3.1 Pro","runtime":{"provider":"google-vertex","model":"gemini-3.1-pro-preview","api":"google-vertex","base_url":"/proxy/vertex"}},
+ {"id":"google/gemini-2.5-flash","name":"Gemini 2.5 Flash","runtime":{"provider":"google-vertex","model":"gemini-2.5-flash","api":"google-vertex","base_url":"/proxy/vertex"}},
+ {"id":"x-ai/grok-4.20","name":"Grok 4.20","runtime":{"provider":"xai","model":"x-ai/grok-4.20","api":"openai-responses","base_url":"/proxy/xai/v1"}},
+ {"id":"groq/qwen/qwen3-32b","name":"Qwen 3 32B","runtime":{"provider":"groq","model":"groq/qwen/qwen3-32b","api":"openai-responses","base_url":"/proxy/groq/v1"}},
+ {"id":"openai/gpt-oss-120b","name":"GPT OSS 120B","runtime":{"provider":"a8c","model":"gpt-oss-120b","api":"openai-completions","base_url":"/proxy/a8c/v1"}},
+ {"id":"anthropic/claude-sonnet-4.5","name":"Claude via OpenRouter","metadata":{"family":"claude","provider_logo_url":"/models/providers/anthropic.png"},"runtime":{"provider":"openrouter","model":"anthropic/claude-sonnet-4.5","api":"openai-completions","base_url":"/proxy/openrouter/v1","compat":{"supports_developer_role":false,"thinking_format":"openrouter","cache_control_format":"anthropic"}}}
]}`))
}))
defer server.Close()
@@ -339,7 +386,7 @@ func TestAIServicesCatalogModelsUsesPublishedProviderRoutes(t *testing.T) {
ID: aiid.DefaultProvider,
Provider: ai.ProviderOpenAI,
API: ai.ApiOpenAIResponses,
- BaseURL: server.URL + "/proxy/openai/v1",
+ BaseURL: server.URL,
})
if err != nil {
t.Fatal(err)
@@ -369,12 +416,15 @@ func TestAIServicesCatalogModelsUsesPublishedProviderRoutes(t *testing.T) {
if got := byID["openai/gpt-oss-120b"]; got.API != ai.ApiOpenAICompletions || got.Provider != ai.Provider("a8c") || got.BaseURL != server.URL+"/proxy/a8c/v1" {
t.Fatalf("unexpected A8C route %#v", got)
}
- if got := byID["anthropic/claude-sonnet-4.5"]; got.API != ai.ApiOpenAIResponses || got.Provider != ai.ProviderOpenRouter || got.BaseURL != server.URL+"/proxy/openrouter/v1" {
+ if got := byID["anthropic/claude-sonnet-4.5"]; got.API != ai.ApiOpenAICompletions || got.Provider != ai.ProviderOpenRouter || got.BaseURL != server.URL+"/proxy/openrouter/v1" {
t.Fatalf("unexpected OpenRouter route %#v", got)
}
- if got := byID["anthropic/claude-sonnet-4.5"]; got.Compat["provider_logo_url"] != "/models/providers/anthropic.png" || got.Compat["provider_model_id"] != "anthropic/claude-sonnet-4.5" || got.Compat["family"] != "claude" {
+ if got := byID["anthropic/claude-sonnet-4.5"]; got.Compat["provider_logo_url"] != "/models/providers/anthropic.png" || got.Compat["runtime_model"] != "anthropic/claude-sonnet-4.5" || got.Compat["family"] != "claude" {
t.Fatalf("expected AI Services catalog identity metadata, got %#v", got.Compat)
}
+ if got := byID["anthropic/claude-sonnet-4.5"]; got.Compat["supportsDeveloperRole"] != false || got.Compat["thinkingFormat"] != "openrouter" || got.Compat["cacheControlFormat"] != "anthropic" {
+ t.Fatalf("expected AI Services catalog provider compat, got %#v", got.Compat)
+ }
}
func TestResolveModelForProviderPreservesOpenAICatalogModelID(t *testing.T) {
@@ -394,20 +444,59 @@ func TestResolveModelForProviderPreservesOpenAICatalogModelID(t *testing.T) {
}
}
-func TestResolveModelForProviderAcceptsArbitraryCustomModelID(t *testing.T) {
+func TestMergeProviderCatalogModelsPreservesConfiguredCustomModels(t *testing.T) {
+ provider := aiid.ProviderConfig{
+ ID: "custom",
+ Provider: ai.ProviderOpenAI,
+ API: ai.ApiOpenAIResponses,
+ BaseURL: "https://custom.test/v1",
+ Models: []ai.Model{
+ {ID: "local-model", Name: "Local Model", Input: []string{"text", "image"}},
+ {ID: "gpt-5.5"},
+ },
+ }
+ catalog := []ai.Model{
+ {
+ ID: "gpt-5.5",
+ Name: "GPT-5.5",
+ Provider: ai.ProviderOpenAI,
+ API: ai.ApiOpenAIResponses,
+ BaseURL: "https://custom.test/v1",
+ Reasoning: true,
+ DefaultThinkingLevel: ai.ModelThinkingLevelLow,
+ ContextWindow: 128000,
+ },
+ {
+ ID: "catalog-only",
+ Provider: ai.ProviderOpenAI,
+ API: ai.ApiOpenAIResponses,
+ },
+ }
+
+ models := mergeProviderCatalogModels(provider, catalog)
+ if len(models) != 2 {
+ t.Fatalf("expected only configured models, got %#v", models)
+ }
+ if models[0].ID != "local-model" || models[0].Name != "Local Model" || !isImageModel(models[0]) {
+ t.Fatalf("expected configured local model to be preserved, got %#v", models[0])
+ }
+ if models[1].ID != "gpt-5.5" || !models[1].Reasoning || models[1].ContextWindow != 128000 {
+ t.Fatalf("expected matching configured model to inherit catalog metadata, got %#v", models[1])
+ }
+}
+
+func TestResolveModelForProviderRejectsUnlistedCustomModelID(t *testing.T) {
provider := aiid.ProviderConfig{
ID: "custom-openai",
Provider: ai.ProviderOpenAI,
API: ai.ApiOpenAIResponses,
Models: []ai.Model{{ID: "gpt-5.5", Provider: ai.ProviderOpenAI, API: ai.ApiOpenAIResponses}},
}
- model, ok := resolveModelForProvider(provider, "custom-openai/openai/gpt-5.5")
- if !ok || model.ID != "openai/gpt-5.5" {
- t.Fatalf("expected arbitrary model ID to resolve, got ok=%v model=%#v", ok, model)
+ if model, ok := resolveModelForProvider(provider, "custom-openai/openai/gpt-5.5"); ok {
+ t.Fatalf("expected unlisted model ID to be rejected, got %#v", model)
}
- model, ok = resolveModelForProvider(provider, "whateveristyped")
- if !ok || model.ID != "whateveristyped" {
- t.Fatalf("expected bare arbitrary model ID to resolve, got ok=%v model=%#v", ok, model)
+ if model, ok := resolveModelForProvider(provider, "whateveristyped"); ok {
+ t.Fatalf("expected arbitrary model ID to be rejected, got %#v", model)
}
}
@@ -507,7 +596,7 @@ func TestSearchUsersFiltersModelContacts(t *testing.T) {
}
}
-func TestSearchUsersAddsArbitraryModelContact(t *testing.T) {
+func TestSearchUsersRejectsArbitraryModelContact(t *testing.T) {
provider := aiid.ProviderConfig{
ID: "local",
DisplayName: "Local",
@@ -523,12 +612,8 @@ func TestSearchUsersAddsArbitraryModelContact(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if len(results) != 1 {
- t.Fatalf("expected arbitrary model contact, got %#v", results)
- }
- name := results[0].UserInfo.Name
- if name == nil || *name != "Local: whateveristyped" {
- t.Fatalf("unexpected arbitrary model contact name %#v", results[0].UserInfo)
+ if len(results) != 0 {
+ t.Fatalf("expected no arbitrary model contact, got %#v", results)
}
}
diff --git a/pkg/connector/login.go b/pkg/connector/login.go
index f969afb4..968013b3 100644
--- a/pkg/connector/login.go
+++ b/pkg/connector/login.go
@@ -2,12 +2,9 @@ package connector
import (
"context"
- "encoding/json"
"fmt"
- "net/http"
"net/url"
"strings"
- "time"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
@@ -22,6 +19,8 @@ const (
loginFlowOpenAIResponses = "openai-responses"
loginFlowOpenAICompletions = "openai-completions"
loginFlowOpenAICodexResponses = "openai-codex-responses"
+ loginFlowAnthropicMessages = "anthropic-messages"
+ loginFlowGoogleVertex = "google-vertex"
loginFlowChatGPTDevice = "chatgpt-device"
loginStepBeeper = "com.beeper.ai.login.beeper"
loginStepProviderConfig = "com.beeper.ai.login.provider.config"
@@ -46,6 +45,14 @@ func (c *Connector) GetLoginFlows() []bridgev2.LoginFlow {
Name: "OpenAI Codex Responses",
Description: "Add a provider using the OpenAI Codex Responses API",
ID: loginFlowOpenAICodexResponses,
+ }, {
+ Name: "Anthropic",
+ Description: "Add a provider using the Anthropic Messages API",
+ ID: loginFlowAnthropicMessages,
+ }, {
+ Name: "Google Vertex",
+ Description: "Add a provider using the Google Vertex API",
+ ID: loginFlowGoogleVertex,
}, {
Name: "ChatGPT",
Description: "Log in with ChatGPT using a browser device code",
@@ -63,6 +70,10 @@ func (c *Connector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID
return &CustomProviderLogin{Main: c, User: user, config: providerLoginConfig{API: ai.ApiOpenAICompletions}}, nil
case loginFlowOpenAICodexResponses:
return &CustomProviderLogin{Main: c, User: user, config: providerLoginConfig{API: ai.ApiOpenAICodexResponses}}, nil
+ case loginFlowAnthropicMessages:
+ return &CustomProviderLogin{Main: c, User: user, config: providerLoginConfig{API: ai.ApiAnthropicMessages}}, nil
+ case loginFlowGoogleVertex:
+ return &CustomProviderLogin{Main: c, User: user, config: providerLoginConfig{API: ai.ApiGoogleVertex}}, nil
case loginFlowChatGPTDevice:
return &ChatGPTDeviceLogin{Main: c, User: user}, nil
default:
@@ -144,7 +155,7 @@ func (l *CustomProviderLogin) providerConfigStep() *bridgev2.LoginStep {
Instructions: "Enter provider connection details",
UserInputParams: &bridgev2.LoginUserInputParams{Fields: []bridgev2.LoginInputDataField{
{Type: bridgev2.LoginInputFieldTypeUsername, ID: "provider_id", Name: "ID", Description: "Stable provider ID"},
- {Type: bridgev2.LoginInputFieldTypeURL, ID: "base_url", Name: "Base URL", Description: "OpenAI-compatible API base URL", DefaultValue: defaultBaseURLForAPI(l.config.API)},
+ {Type: bridgev2.LoginInputFieldTypeURL, ID: "base_url", Name: "Base URL", Description: "Provider API base URL", DefaultValue: defaultBaseURLForAPI(l.config.API)},
{Type: bridgev2.LoginInputFieldTypeToken, ID: "api_key", Name: "API key", Description: "Provider API key"},
}},
}
@@ -162,18 +173,24 @@ func (l *CustomProviderLogin) submitProviderConfig(ctx context.Context, input ma
log.Err(err).Msg("Provider login config rejected")
return nil, err
}
- models, err := fetchProviderModels(ctx, l.config.API, providerID, baseURL, apiKey)
+ provider, err := l.Main.providerConfigFromInput(ctx, l.User.MXID, ProviderInput{
+ ID: providerID,
+ API: l.config.API,
+ BaseURL: baseURL,
+ APIKey: apiKey,
+ DefaultModel: "",
+ })
if err != nil {
- log.Err(err).Msg("Failed to fetch provider models during login")
+ log.Err(err).Msg("Failed to load provider models during login")
return nil, err
}
- log.Debug().Int("model_count", len(models)).Msg("Fetched provider models during login")
- l.config.ProviderID = providerID
- l.config.BaseURL = baseURL
- l.config.APIKey = apiKey
- l.config.Models = models
- options := make([]string, 0, len(models))
- for _, model := range models {
+ log.Debug().Int("model_count", len(provider.Models)).Msg("Loaded provider models during login")
+ l.config.ProviderID = provider.ID
+ l.config.BaseURL = provider.BaseURL
+ l.config.APIKey = provider.APIKey
+ l.config.Models = provider.Models
+ options := make([]string, 0, len(provider.Models))
+ for _, model := range provider.Models {
options = append(options, model.ID)
}
if len(options) == 0 {
@@ -255,7 +272,7 @@ type providerLoginConfig struct {
func supportedProviderLoginAPI(api ai.Api) bool {
switch api {
- case ai.ApiOpenAIResponses, ai.ApiOpenAICompletions, ai.ApiOpenAICodexResponses:
+ case ai.ApiOpenAIResponses, ai.ApiOpenAICompletions, ai.ApiOpenAICodexResponses, ai.ApiAnthropicMessages, ai.ApiGoogleVertex:
return true
default:
return false
@@ -263,7 +280,16 @@ func supportedProviderLoginAPI(api ai.Api) bool {
}
func defaultBaseURLForAPI(api ai.Api) string {
- return "https://api.openai.com/v1"
+ switch api {
+ case ai.ApiAnthropicMessages:
+ return "https://api.anthropic.com"
+ case ai.ApiGoogleVertex:
+ return "https://aiplatform.googleapis.com"
+ case ai.ApiOpenAICompletions, ai.ApiOpenAIResponses, ai.ApiOpenAICodexResponses:
+ return "https://api.openai.com/v1"
+ default:
+ return ""
+ }
}
func providerDisplayName(providerID string) string {
@@ -272,9 +298,11 @@ func providerDisplayName(providerID string) string {
return "Provider"
}
known := map[string]string{
- "openai": "OpenAI",
- "openrouter": "OpenRouter",
- "lmstudio": "LM Studio",
+ "anthropic": "Anthropic",
+ "google-vertex": "Google Vertex",
+ "openai": "OpenAI",
+ "openrouter": "OpenRouter",
+ "vertex": "Google Vertex",
}
if name, ok := known[strings.ToLower(providerID)]; ok {
return name
@@ -291,98 +319,6 @@ func providerDisplayName(providerID string) string {
return strings.Join(parts, " ")
}
-func fetchProviderModels(ctx context.Context, api ai.Api, providerID string, baseURL string, apiKey string) ([]ai.Model, error) {
- if !supportedProviderLoginAPI(api) {
- return nil, fmt.Errorf("unsupported API type %s", api)
- }
- modelURL, err := url.JoinPath(baseURL, "models")
- if err != nil {
- return nil, fmt.Errorf("invalid base URL: %w", err)
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelURL, nil)
- if err != nil {
- return nil, err
- }
- if apiKey != "" {
- req.Header.Set("Authorization", "Bearer "+resolveConfiguredAPIKey(apiKey))
- }
- client := &http.Client{Timeout: 20 * time.Second}
- logCtx := zerolog.Ctx(ctx).With().
- Str("action", "ai_provider_models_http").
- Str("provider_id", providerID).
- Str("api", string(api)).
- Str("method", http.MethodGet)
- if parsed, parseErr := url.Parse(modelURL); parseErr == nil {
- logCtx = logCtx.Str("url", parsed.Redacted()).Str("host", parsed.Host).Str("path", parsed.EscapedPath())
- } else {
- logCtx = logCtx.Str("url", modelURL)
- }
- log := logCtx.Logger()
- ctx = log.WithContext(ctx)
- log.Trace().Msg("Sending provider models HTTP request")
- started := time.Now()
- resp, err := client.Do(req)
- if err != nil {
- log.Err(err).Dur("duration", time.Since(started)).Msg("Provider models HTTP request failed")
- return nil, fmt.Errorf("failed to fetch models: %w", err)
- }
- defer resp.Body.Close()
- logEvent := log.Debug()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- logEvent = log.Error()
- }
- logEvent.Dur("duration", time.Since(started)).
- Int("status_code", resp.StatusCode).
- Str("status", resp.Status).
- Int64("response_content_length", resp.ContentLength).
- Str("response_content_type", resp.Header.Get("Content-Type")).
- Msg("Received provider models HTTP response")
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("failed to fetch models: provider returned HTTP %d", resp.StatusCode)
- }
- var body aiServicesModelListResponse
- if err = json.NewDecoder(resp.Body).Decode(&body); err != nil {
- return nil, fmt.Errorf("failed to parse models response: %w", err)
- }
- models := make([]ai.Model, 0, len(body.Data))
- seen := map[string]bool{}
- provider, inferredAPI := inferProviderRoute(providerID, baseURL)
- if api != "" {
- inferredAPI = api
- }
- providerConfig := aiid.ProviderConfig{
- ID: providerID,
- API: inferredAPI,
- Provider: configuredModelProvider(providerID, provider),
- BaseURL: baseURL,
- }
- for _, item := range body.Data {
- modelID := strings.TrimSpace(item.ID)
- if modelID == "" || seen[modelID] {
- continue
- }
- seen[modelID] = true
- model := ai.Model{
- ID: modelID,
- Name: item.Name,
- API: inferredAPI,
- Provider: providerConfig.Provider,
- BaseURL: baseURL,
- Reasoning: item.reasoning(),
- Input: item.inputModalities(),
- ContextWindow: item.contextWindow(),
- MaxTokens: item.maxTokens(),
- }
- model = item.applyProviderRoute(model, providerConfig)
- models = append(models, normalizeProviderModel(model, providerConfig))
- }
- if len(models) == 0 {
- return nil, fmt.Errorf("provider returned no models")
- }
- log.Debug().Int("model_count", len(models)).Msg("Parsed provider models")
- return models, nil
-}
-
func customProviderConfig(providerID string, displayName string, baseURL string, apiKey string, defaultModel string, modelList string) aiid.ProviderConfig {
provider, api := inferProviderRoute(providerID, baseURL)
modelIDs := providerModelIDs(modelList, defaultModel)
@@ -404,7 +340,16 @@ func inferProviderRoute(providerID string, baseURL string) (ai.Provider, ai.Api)
if providerID == string(ai.ProviderOpenRouter) || strings.Contains(baseURL, "openrouter.ai") {
return ai.ProviderOpenRouter, ai.ApiOpenAICompletions
}
- return ai.ProviderOpenAI, ai.ApiOpenAIResponses
+ if providerID == string(ai.ProviderAnthropic) || strings.Contains(baseURL, "anthropic.com") {
+ return ai.ProviderAnthropic, ai.ApiAnthropicMessages
+ }
+ if providerID == string(ai.ProviderGoogleVertex) || providerID == "vertex" || strings.Contains(baseURL, "aiplatform.googleapis.com") {
+ return ai.ProviderGoogleVertex, ai.ApiGoogleVertex
+ }
+ if providerID == string(ai.ProviderOpenAI) || strings.Contains(baseURL, "api.openai.com") {
+ return ai.ProviderOpenAI, ai.ApiOpenAIResponses
+ }
+ return "", ""
}
func providerModels(modelList string, defaultModel string, providerID string, baseURL string) []ai.Model {
@@ -452,9 +397,6 @@ func providerModelsFromIDs(modelIDs []string, providerID string, provider ai.Pro
}
func configuredModelProvider(providerID string, provider ai.Provider) ai.Provider {
- if providerID != string(ai.ProviderOpenAI) && providerID != string(ai.ProviderOpenRouter) {
- return ai.Provider(providerID)
- }
return provider
}
diff --git a/pkg/connector/model_avatar.go b/pkg/connector/model_avatar.go
index fe498579..7c4402ef 100644
--- a/pkg/connector/model_avatar.go
+++ b/pkg/connector/model_avatar.go
@@ -136,7 +136,7 @@ func remoteModelAvatar(logoID string, logoURL string) *bridgev2.Avatar {
}
}
-func aiServicesProviderLogoURL(proxyBaseURL string, providerLogoURL string) (string, error) {
+func aiServicesProviderLogoURL(baseURL string, providerLogoURL string) (string, error) {
providerLogoURL = strings.TrimSpace(providerLogoURL)
if providerLogoURL == "" {
return "", fmt.Errorf("empty provider logo URL")
@@ -152,17 +152,17 @@ func aiServicesProviderLogoURL(proxyBaseURL string, providerLogoURL string) (str
logo.Fragment = ""
return logo.String(), nil
}
- if proxyBaseURL == "" {
+ if baseURL == "" {
return "", fmt.Errorf("missing AI Services base URL for provider logo %q", providerLogoURL)
}
- base, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(proxyBaseURL), "/"))
+ base, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(baseURL), "/"))
if err != nil {
return "", err
}
if base.Scheme == "" || base.Host == "" {
- return "", fmt.Errorf("invalid AI Services base URL %q", proxyBaseURL)
+ return "", fmt.Errorf("invalid AI Services base URL %q", baseURL)
}
- base.Path = joinURLPath(trimAIProxyProviderPath(base.Path), logo.Path)
+ base.Path = joinURLPath(base.Path, logo.Path)
base.RawQuery = logo.RawQuery
base.Fragment = ""
return base.String(), nil
@@ -187,7 +187,7 @@ func modelAvatarProviderKeyFromHints(model ai.Model) string {
if key := providerLogoURLAvatarKey(compatString(model.Compat["provider_logo_url"])); key != "" {
return key
}
- if key := modelAvatarProviderKeyFromModelID(compatString(model.Compat["provider_model_id"])); key != "" {
+ if key := modelAvatarProviderKeyFromModelID(compatString(model.Compat["runtime_model"])); key != "" {
return key
}
return modelAvatarProviderKeyFromFamily(compatString(model.Compat["family"]))
diff --git a/pkg/connector/model_avatar_test.go b/pkg/connector/model_avatar_test.go
index d549d0d0..dc021fe0 100644
--- a/pkg/connector/model_avatar_test.go
+++ b/pkg/connector/model_avatar_test.go
@@ -21,7 +21,7 @@ func TestModelAvatarUsesEmbeddedPNGAssets(t *testing.T) {
{name: "google vertex", model: ai.Model{ID: "gemini-3-pro", Provider: ai.ProviderGoogleVertex}, wantKey: "google"},
{name: "openrouter own model", model: ai.Model{ID: "openrouter/owl-alpha", Provider: ai.ProviderOpenRouter}, wantKey: "openrouter"},
{name: "catalog provider logo wins over route", model: ai.Model{ID: "openrouter/anthropic/claude-opus-4.8-fast", Provider: ai.ProviderOpenRouter, Compat: map[string]any{"provider_logo_url": "/models/providers/anthropic.png"}}, wantKey: "anthropic"},
- {name: "catalog provider model wins over route", model: ai.Model{ID: "openrouter/z-ai/glm-5", Provider: ai.ProviderOpenRouter, Compat: map[string]any{"provider_model_id": "z-ai/glm-5"}}, wantKey: "zai"},
+ {name: "catalog runtime model wins over route", model: ai.Model{ID: "openrouter/z-ai/glm-5", Provider: ai.ProviderOpenRouter, Compat: map[string]any{"runtime_model": "z-ai/glm-5"}}, wantKey: "zai"},
{name: "catalog family wins over route", model: ai.Model{ID: "openrouter/xiaomi/mimo-v2.5", Provider: ai.ProviderOpenRouter, Compat: map[string]any{"family": "mimo"}}, wantKey: "xiaomi"},
{name: "openrouter routed anthropic model", model: ai.Model{ID: "anthropic/claude-sonnet-4.5", Provider: ai.ProviderOpenRouter}, wantKey: "anthropic"},
{name: "openrouter routed bare claude model", model: ai.Model{ID: "claude-sonnet-4-5", Provider: ai.ProviderOpenRouter}, wantKey: "anthropic"},
@@ -70,7 +70,7 @@ func TestModelAvatarFetchesCatalogProviderLogoFromAIServices(t *testing.T) {
}))
defer server.Close()
- avatar := modelAvatar(aiid.ProviderConfig{BaseURL: server.URL + "/proxy/openai/v1"}, ai.Model{
+ avatar := modelAvatar(aiid.ProviderConfig{BaseURL: server.URL}, ai.Model{
ID: "qwen/qwen3-max",
Provider: ai.ProviderOpenRouter,
Compat: map[string]any{"provider_logo_url": "/models/providers/qwen.png"},
@@ -94,7 +94,7 @@ func TestModelAvatarFetchesCatalogProviderLogoFromAIServices(t *testing.T) {
}
func TestAIServicesProviderLogoURL(t *testing.T) {
- got, err := aiServicesProviderLogoURL("https://ai-services.example/dev/proxy/openrouter/v1", "/models/providers/anthropic.png")
+ got, err := aiServicesProviderLogoURL("https://ai-services.example/dev", "/models/providers/anthropic.png")
if err != nil {
t.Fatal(err)
}
@@ -102,7 +102,7 @@ func TestAIServicesProviderLogoURL(t *testing.T) {
t.Fatalf("unexpected logo URL %q, want %q", got, want)
}
- got, err = aiServicesProviderLogoURL("https://ai-services.example/proxy/openai/v1/responses", "models/providers/zai.png?cache=1")
+ got, err = aiServicesProviderLogoURL("https://ai-services.example", "models/providers/zai.png?cache=1")
if err != nil {
t.Fatal(err)
}
diff --git a/pkg/connector/provider.go b/pkg/connector/provider.go
index a30eab82..9ad04a9a 100644
--- a/pkg/connector/provider.go
+++ b/pkg/connector/provider.go
@@ -10,6 +10,7 @@ import (
"time"
"github.com/rs/zerolog"
+ "maunium.net/go/mautrix/id"
"github.com/beeper/ai-bridge/pkg/agent/harness"
ai "github.com/beeper/ai-bridge/pkg/ai"
@@ -44,29 +45,31 @@ func (cl *Client) resolveProvider(ctx context.Context, roomConfig RoomConfig) (a
}
log := logCtx.Logger()
ctx = log.WithContext(ctx)
- provider, modelID, err := cl.Main.ResolveProvider(ctx, cl.UserLogin, roomConfig)
- if err != nil {
+ providerID := roomConfig.ProviderID
+ if providerID == "" {
+ providerID = aiid.DefaultProvider
+ }
+ provider, ok := cl.Main.providersForLogin(cl.UserLogin)[providerID]
+ if !ok {
+ err := fmt.Errorf("provider %s is not available for login %s", providerID, cl.UserLogin.ID)
log.Err(err).Msg("Failed to resolve AI provider")
return aiid.ProviderConfig{}, "", err
}
- if provider.ID != aiid.DefaultProvider {
- log.Debug().
- Str("provider_id", provider.ID).
- Str("provider", string(provider.Provider)).
- Str("model_id", modelID).
- Msg("Resolved AI provider")
- return provider, modelID, nil
- }
+ var err error
provider, err = cl.providerWithCatalogModelsStrict(ctx, provider)
if err != nil {
- log.Err(err).Str("provider_id", provider.ID).Msg("Failed to load default AI provider model catalog")
+ log.Err(err).Str("provider_id", provider.ID).Msg("Failed to load AI provider model catalog")
return aiid.ProviderConfig{}, "", err
}
if len(provider.Models) == 0 {
- err := fmt.Errorf("Beeper AI model catalog is unavailable")
- log.Err(err).Str("provider_id", provider.ID).Msg("Default AI provider model catalog is empty")
+ err := fmt.Errorf("AI model catalog is unavailable for provider %s", provider.ID)
+ log.Err(err).Str("provider_id", provider.ID).Msg("AI provider model catalog is empty")
return aiid.ProviderConfig{}, "", err
}
+ modelID := roomConfig.ModelID
+ if modelID == "" {
+ modelID = provider.DefaultModel
+ }
if resolvedModelID, ok := resolveProviderModelID(provider, modelID); ok {
log.Debug().
Str("provider_id", provider.ID).
@@ -125,24 +128,6 @@ func normalizeProviderModel(model ai.Model, provider aiid.ProviderConfig) ai.Mod
if model.Name == "" {
model.Name = model.ID
}
- if provider.ID != aiid.DefaultProvider {
- if catalogModel, ok := ai.GetModel(model.Provider, model.ID); ok {
- if len(model.Input) == 0 && len(catalogModel.Input) > 0 {
- model.Input = append([]string(nil), catalogModel.Input...)
- }
- if !model.Reasoning {
- model.Reasoning = catalogModel.Reasoning
- }
- if len(model.ThinkingLevelMap) == 0 && len(catalogModel.ThinkingLevelMap) > 0 {
- model.ThinkingLevelMap = catalogModel.ThinkingLevelMap
- }
- if model.DefaultThinkingLevel == "" {
- model.DefaultThinkingLevel = catalogModel.DefaultThinkingLevel
- }
- } else if len(model.Input) == 0 {
- model.Input = catalogInputForProviderModel(model)
- }
- }
if len(model.Input) == 0 {
model.Input = []string{"text"}
}
@@ -150,18 +135,6 @@ func normalizeProviderModel(model ai.Model, provider aiid.ProviderConfig) ai.Mod
return model
}
-func catalogInputForProviderModel(model ai.Model) []string {
- prefix := string(model.Provider) + "/"
- if !strings.HasPrefix(model.ID, prefix) {
- return nil
- }
- catalogModel, ok := ai.GetModel(model.Provider, strings.TrimPrefix(model.ID, prefix))
- if !ok || len(catalogModel.Input) == 0 {
- return nil
- }
- return append([]string(nil), catalogModel.Input...)
-}
-
func (cl *Client) authForProvider(provider aiid.ProviderConfig) func(context.Context, ai.Model) (*harness.AgentHarnessAuth, error) {
return func(ctx context.Context, model ai.Model) (*harness.AgentHarnessAuth, error) {
logCtx := zerolog.Ctx(ctx).With().
@@ -205,15 +178,22 @@ func (cl *Client) defaultProviderBearerToken() (string, error) {
if cl == nil || cl.Main == nil {
return "", fmt.Errorf("missing connector for default provider")
}
- if cl.Main.AppServiceToken == "" {
+ if cl.UserLogin == nil {
+ return "", fmt.Errorf("missing user login for default provider")
+ }
+ return cl.Main.defaultProviderBearerToken(cl.UserLogin.UserMXID)
+}
+
+func (c *Connector) defaultProviderBearerToken(userMXID id.UserID) (string, error) {
+ if c == nil || c.AppServiceToken == "" {
return "", fmt.Errorf("missing appservice token for default provider")
}
- username := cl.defaultProviderUsername()
+ username := userMXID.Localpart()
if username == "" {
return "", fmt.Errorf("missing Beeper username for default provider")
}
payload, err := json.Marshal(aiServicesAppserviceToken{
- ASToken: cl.Main.AppServiceToken,
+ ASToken: c.AppServiceToken,
Username: username,
})
if err != nil {
@@ -222,13 +202,6 @@ func (cl *Client) defaultProviderBearerToken() (string, error) {
return aiServicesAppserviceTokenPrefix + base64.RawURLEncoding.EncodeToString(payload), nil
}
-func (cl *Client) defaultProviderUsername() string {
- if cl == nil || cl.UserLogin == nil || cl.UserLogin.UserLogin == nil {
- return ""
- }
- return cl.UserLogin.UserMXID.Localpart()
-}
-
func (cl *Client) refreshProviderIfNeeded(ctx context.Context, provider aiid.ProviderConfig) (aiid.ProviderConfig, error) {
if provider.ID != chatGPTProviderID || provider.RefreshToken == "" || provider.ExpiresAtMS == 0 {
return provider, nil
diff --git a/pkg/connector/provider_lifecycle.go b/pkg/connector/provider_lifecycle.go
index 472d5f2f..a1402766 100644
--- a/pkg/connector/provider_lifecycle.go
+++ b/pkg/connector/provider_lifecycle.go
@@ -10,6 +10,8 @@ import (
"strings"
"maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/id"
ai "github.com/beeper/ai-bridge/pkg/ai"
"github.com/beeper/ai-bridge/pkg/aiid"
@@ -40,7 +42,14 @@ type ProviderResponse struct {
ReadOnly bool `json:"read_only,omitempty"`
}
-func (c *Connector) VerifyProviderConfig(ctx context.Context, input ProviderInput) (aiid.ProviderConfig, error) {
+func (c *Connector) VerifyProviderConfig(ctx context.Context, login *bridgev2.UserLogin, input ProviderInput) (aiid.ProviderConfig, error) {
+ if login == nil {
+ return aiid.ProviderConfig{}, fmt.Errorf("login is required")
+ }
+ return c.providerConfigFromInput(ctx, login.UserMXID, input)
+}
+
+func (c *Connector) providerConfigFromInput(ctx context.Context, userMXID id.UserID, input ProviderInput) (aiid.ProviderConfig, error) {
input.ID = strings.TrimSpace(input.ID)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.BaseURL = normalizeResponsesBaseURL(strings.TrimSpace(input.BaseURL))
@@ -52,30 +61,23 @@ func (c *Connector) VerifyProviderConfig(ctx context.Context, input ProviderInpu
if input.ID == aiid.DefaultProvider {
return aiid.ProviderConfig{}, fmt.Errorf("provider %q is managed by Beeper AI", aiid.DefaultProvider)
}
+ provider, inferredAPI := inferProviderRoute(input.ID, input.BaseURL)
if input.API == "" {
- provider, api := inferProviderRoute(input.ID, input.BaseURL)
- input.API = api
+ input.API = inferredAPI
if input.DisplayName == "" {
input.DisplayName = providerDisplayName(string(provider))
}
}
+ if !supportedProviderLoginAPI(input.API) || !providerAPIAllowed(provider, input.API) {
+ return aiid.ProviderConfig{}, fmt.Errorf("provider %s with API %s is not supported", input.ID, input.API)
+ }
if input.BaseURL == "" || input.APIKey == "" {
return aiid.ProviderConfig{}, fmt.Errorf("base_url and api_key are required")
}
- models, err := fetchProviderModels(ctx, input.API, input.ID, input.BaseURL, input.APIKey)
- if err != nil {
- return aiid.ProviderConfig{}, err
- }
- if input.DefaultModel == "" {
- input.DefaultModel = models[0].ID
- } else if !providerHasModel(aiid.ProviderConfig{Models: models}, input.DefaultModel) {
- return aiid.ProviderConfig{}, fmt.Errorf("model %s was not returned by provider %s", input.DefaultModel, input.ID)
- }
- provider, _ := inferProviderRoute(input.ID, input.BaseURL)
if input.DisplayName == "" {
input.DisplayName = providerDisplayName(input.ID)
}
- return aiid.ProviderConfig{
+ providerConfig := aiid.ProviderConfig{
ID: input.ID,
DisplayName: input.DisplayName,
API: input.API,
@@ -83,8 +85,48 @@ func (c *Connector) VerifyProviderConfig(ctx context.Context, input ProviderInpu
BaseURL: input.BaseURL,
APIKey: input.APIKey,
DefaultModel: input.DefaultModel,
- Models: models,
- }, nil
+ }
+ models, err := c.aiServicesCatalogModelsForUserProvider(ctx, userMXID, providerConfig)
+ if err != nil {
+ return aiid.ProviderConfig{}, err
+ }
+ if len(models) == 0 {
+ return aiid.ProviderConfig{}, fmt.Errorf("AI Services catalog has no models for provider %s", providerConfig.Provider)
+ }
+ providerConfig.Models = models
+ if providerConfig.DefaultModel == "" {
+ providerConfig.DefaultModel = models[0].ID
+ } else if resolved, ok := resolveProviderModelID(aiid.ProviderConfig{Models: models}, providerConfig.DefaultModel); ok {
+ providerConfig.DefaultModel = resolved
+ } else {
+ return aiid.ProviderConfig{}, fmt.Errorf("model %s was not returned by AI Services for provider %s", providerConfig.DefaultModel, input.ID)
+ }
+ return providerConfig, nil
+}
+
+func (c *Connector) aiServicesCatalogModelsForUserProvider(ctx context.Context, userMXID id.UserID, provider aiid.ProviderConfig) ([]ai.Model, error) {
+ client := &Client{
+ Main: c,
+ UserLogin: &bridgev2.UserLogin{
+ UserLogin: &database.UserLogin{UserMXID: userMXID},
+ },
+ }
+ return client.aiServicesCatalogModels(ctx, provider)
+}
+
+func providerAPIAllowed(provider ai.Provider, api ai.Api) bool {
+ switch provider {
+ case ai.ProviderOpenAI:
+ return api == ai.ApiOpenAIResponses || api == ai.ApiOpenAICompletions || api == ai.ApiOpenAICodexResponses
+ case ai.ProviderOpenRouter:
+ return api == ai.ApiOpenAICompletions
+ case ai.ProviderAnthropic:
+ return api == ai.ApiAnthropicMessages
+ case ai.ProviderGoogleVertex:
+ return api == ai.ApiGoogleVertex
+ default:
+ return false
+ }
}
func (c *Connector) SaveProviderConfig(ctx context.Context, login *bridgev2.UserLogin, provider aiid.ProviderConfig) error {
diff --git a/pkg/connector/provider_routes.go b/pkg/connector/provider_routes.go
index ef50f9ef..dd117db2 100644
--- a/pkg/connector/provider_routes.go
+++ b/pkg/connector/provider_routes.go
@@ -14,6 +14,8 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid"
)
+var errInvalidProviderLoginID = errors.New("invalid provider login_id")
+
type providersListResponse struct {
Providers []ProviderResponse `json:"providers"`
}
@@ -47,7 +49,7 @@ func (c *Connector) handleProvidersList(provisioning bridgev2.IProvisioningAPI)
return func(w http.ResponseWriter, r *http.Request) {
login, err := c.loginForProviderRequest(r.Context(), provisioning.GetUser(r), r.URL.Query().Get("login_id"))
if err != nil {
- writeProviderError(w, providerErrorStatus(err), err)
+ writeProviderError(w, providerRequestErrorStatus(err), err)
return
}
writeProviderJSON(r.Context(), w, http.StatusOK, providersListResponse{Providers: sortedProviderResponses(c.providersForLogin(login))})
@@ -58,7 +60,7 @@ func (c *Connector) handleProvidersGet(provisioning bridgev2.IProvisioningAPI) h
return func(w http.ResponseWriter, r *http.Request) {
login, err := c.loginForProviderRequest(r.Context(), provisioning.GetUser(r), r.URL.Query().Get("login_id"))
if err != nil {
- writeProviderError(w, providerErrorStatus(err), err)
+ writeProviderError(w, providerRequestErrorStatus(err), err)
return
}
providerID := strings.TrimSpace(r.PathValue("providerID"))
@@ -96,14 +98,14 @@ func (c *Connector) handleProviderUpsert(w http.ResponseWriter, r *http.Request,
}
input.ID = routeProviderID
}
- provider, err := c.VerifyProviderConfig(r.Context(), input)
+ login, err := c.loginForProviderRequest(r.Context(), provisioning.GetUser(r), r.URL.Query().Get("login_id"))
if err != nil {
- writeProviderError(w, http.StatusBadRequest, err)
+ writeProviderError(w, providerRequestErrorStatus(err), err)
return
}
- login, err := c.loginForProviderRequest(r.Context(), provisioning.GetUser(r), r.URL.Query().Get("login_id"))
+ provider, err := c.VerifyProviderConfig(r.Context(), login, input)
if err != nil {
- writeProviderError(w, providerErrorStatus(err), err)
+ writeProviderError(w, http.StatusBadRequest, err)
return
}
if err = c.SaveProviderConfig(r.Context(), login, provider); err != nil {
@@ -121,7 +123,7 @@ func (c *Connector) handleProvidersDelete(provisioning bridgev2.IProvisioningAPI
return func(w http.ResponseWriter, r *http.Request) {
login, err := c.loginForProviderRequest(r.Context(), provisioning.GetUser(r), r.URL.Query().Get("login_id"))
if err != nil {
- writeProviderError(w, providerErrorStatus(err), err)
+ writeProviderError(w, providerRequestErrorStatus(err), err)
return
}
err = c.DeleteProvider(r.Context(), login, r.PathValue("providerID"))
@@ -143,7 +145,7 @@ func (c *Connector) loginForProviderRequest(ctx context.Context, user *bridgev2.
return nil, err
}
if login == nil || login.UserMXID != user.MXID {
- return nil, fmt.Errorf("login %s not found", loginID)
+ return nil, fmt.Errorf("%w: login %s not found", errInvalidProviderLoginID, loginID)
}
if err := c.ensureAIChatsMetadata(ctx, login); err != nil {
return nil, err
@@ -159,6 +161,13 @@ func writeProviderJSON(ctx context.Context, w http.ResponseWriter, status int, b
}
}
+func providerRequestErrorStatus(err error) int {
+ if errors.Is(err, errInvalidProviderLoginID) {
+ return http.StatusBadRequest
+ }
+ return providerErrorStatus(err)
+}
+
func providerErrorStatus(err error) int {
if err == nil {
return http.StatusOK
diff --git a/pkg/connector/provider_test.go b/pkg/connector/provider_test.go
index 0d069a1b..0171a044 100644
--- a/pkg/connector/provider_test.go
+++ b/pkg/connector/provider_test.go
@@ -4,8 +4,6 @@ import (
"context"
"encoding/base64"
"encoding/json"
- "net/http"
- "net/http/httptest"
"strings"
"testing"
@@ -47,7 +45,7 @@ func TestModelForProviderBuildsDefaultProviderModelFromConfig(t *testing.T) {
ID: aiid.DefaultProvider,
API: ai.ApiOpenAIResponses,
Provider: ai.ProviderOpenAI,
- BaseURL: "https://ai-services.beeper-staging.com/proxy/openai/v1",
+ BaseURL: "https://ai-services.beeper-staging.com",
}
model := conn.ModelForProvider(provider, "openai/gpt-5.5")
if model.ID != "openai/gpt-5.5" || model.Provider != ai.ProviderOpenAI || model.API != ai.ApiOpenAIResponses {
@@ -79,7 +77,7 @@ func TestModelForProviderFillsCustomListedModelInputFromCatalog(t *testing.T) {
API: ai.ApiOpenAIResponses,
Provider: ai.ProviderOpenAI,
BaseURL: "https://custom.test/v1",
- Models: []ai.Model{{ID: "gpt-5.5", Provider: ai.ProviderOpenAI, API: ai.ApiOpenAIResponses}},
+ Models: []ai.Model{{ID: "gpt-5.5", Provider: ai.ProviderOpenAI, API: ai.ApiOpenAIResponses, Input: []string{"text", "image"}}},
}
model := conn.ModelForProvider(provider, "gpt-5.5")
if !isImageModel(model) {
@@ -94,7 +92,7 @@ func TestModelForProviderFillsCustomPrefixedOpenAIInputFromCatalog(t *testing.T)
API: ai.ApiOpenAIResponses,
Provider: ai.ProviderOpenAI,
BaseURL: "https://custom.test/v1",
- Models: []ai.Model{{ID: "openai/gpt-5.5", Provider: ai.ProviderOpenAI, API: ai.ApiOpenAIResponses}},
+ Models: []ai.Model{{ID: "openai/gpt-5.5", Provider: ai.ProviderOpenAI, API: ai.ApiOpenAIResponses, Input: []string{"text", "image"}}},
}
model := conn.ModelForProvider(provider, "openai/gpt-5.5")
if !isImageModel(model) {
@@ -135,7 +133,7 @@ func TestTitleGenerationModelUsesDefaultMini(t *testing.T) {
client := &Client{Main: conn}
provider := conn.defaultProviderConfig("@alice:beeper-staging.com")
model := client.titleGenerationModel(provider, ai.Model{ID: defaultBeeperAIModel})
- if model.ID != defaultTitleGenerationModel || model.Provider != ai.ProviderOpenAI || model.API != ai.ApiOpenAIResponses {
+ if model.ID != defaultBeeperAIModel {
t.Fatalf("unexpected title model %#v", model)
}
}
@@ -238,8 +236,8 @@ func TestDefaultProviderAuthRequiresBeeperUsername(t *testing.T) {
},
}
_, err := client.authForProvider(aiid.ProviderConfig{ID: aiid.DefaultProvider})(context.Background(), ai.Model{})
- if err == nil || !strings.Contains(err.Error(), "Beeper username") {
- t.Fatalf("expected Beeper username error, got %v", err)
+ if err == nil || !strings.Contains(err.Error(), "user login") {
+ t.Fatalf("expected user login error, got %v", err)
}
}
@@ -269,17 +267,17 @@ func TestConfigDefaults(t *testing.T) {
}
}
-func TestDefaultAIServicesOpenAIProxyBaseURLUsesUserHomeserver(t *testing.T) {
+func TestDefaultAIServicesBaseURLUsesUserHomeserver(t *testing.T) {
conn := &Connector{}
tests := map[string]string{
- "@alice:beeper.localtest.me": "https://ai-services.beeper.localtest.me/proxy/openai/v1",
- "@alice:beeper-dev.com": "https://ai-services.beeper-dev.com/proxy/openai/v1",
- "@alice:beeper-staging.com": "https://ai-services.beeper-staging.com/proxy/openai/v1",
- "@alice:beeper.com": "https://ai-services.beeper.com/proxy/openai/v1",
+ "@alice:beeper.localtest.me": "https://ai-services.beeper.localtest.me",
+ "@alice:beeper-dev.com": "https://ai-services.beeper-dev.com",
+ "@alice:beeper-staging.com": "https://ai-services.beeper-staging.com",
+ "@alice:beeper.com": "https://ai-services.beeper.com",
}
for userMXID, want := range tests {
- if got := conn.defaultAIServicesOpenAIProxyBaseURL(id.UserID(userMXID)); got != want {
- t.Fatalf("defaultAIServicesOpenAIProxyBaseURL(%q) = %q, want %q", userMXID, got, want)
+ if got := conn.defaultAIServicesBaseURL(id.UserID(userMXID)); got != want {
+ t.Fatalf("defaultAIServicesBaseURL(%q) = %q, want %q", userMXID, got, want)
}
}
}
@@ -287,7 +285,7 @@ func TestDefaultAIServicesOpenAIProxyBaseURLUsesUserHomeserver(t *testing.T) {
func TestDefaultProviderBaseURLUsesUserHomeserver(t *testing.T) {
conn := &Connector{}
provider := conn.defaultProviderConfig("@alice:beeper-staging.com")
- if provider.BaseURL != "https://ai-services.beeper-staging.com/proxy/openai/v1" {
+ if provider.BaseURL != "https://ai-services.beeper-staging.com" {
t.Fatalf("unexpected provider base URL %q", provider.BaseURL)
}
}
@@ -295,7 +293,7 @@ func TestDefaultProviderBaseURLUsesUserHomeserver(t *testing.T) {
func TestDefaultProviderBaseURLUsesInternalLocalServiceForLocalUser(t *testing.T) {
conn := &Connector{HomeserverURL: "http://megahungry-proxy.megahungry/api/proxy/bridge-user"}
provider := conn.defaultProviderConfig("@alice:beeper.localtest.me")
- if provider.BaseURL != "http://ai-services.beeper/proxy/openai/v1" {
+ if provider.BaseURL != "http://ai-services.beeper" {
t.Fatalf("unexpected provider base URL %q", provider.BaseURL)
}
}
@@ -303,7 +301,7 @@ func TestDefaultProviderBaseURLUsesInternalLocalServiceForLocalUser(t *testing.T
func TestDefaultProviderBaseURLUsesUserHomeserverForMegahungryCloudUser(t *testing.T) {
conn := &Connector{HomeserverURL: "http://megahungry-proxy.megahungry/api/proxy/bridge-user"}
provider := conn.defaultProviderConfig("@alice:beeper-staging.com")
- if provider.BaseURL != "https://ai-services.beeper-staging.com/proxy/openai/v1" {
+ if provider.BaseURL != "https://ai-services.beeper-staging.com" {
t.Fatalf("unexpected provider base URL %q", provider.BaseURL)
}
}
@@ -311,7 +309,7 @@ func TestDefaultProviderBaseURLUsesUserHomeserverForMegahungryCloudUser(t *testi
func TestDefaultProviderBaseURLUsesExternalLocaltestServiceForSelfHosted(t *testing.T) {
conn := &Connector{HomeserverURL: "https://matrix.beeper.localtest.me/_hungryserv/bridge-user"}
provider := conn.defaultProviderConfig("@alice:beeper.localtest.me")
- if provider.BaseURL != "https://ai-services.beeper.localtest.me/proxy/openai/v1" {
+ if provider.BaseURL != "https://ai-services.beeper.localtest.me" {
t.Fatalf("unexpected provider base URL %q", provider.BaseURL)
}
}
@@ -332,7 +330,7 @@ func TestDefaultProviderReadsAIChatsLoginMetadata(t *testing.T) {
Metadata: &aiid.UserLoginMetadata{Providers: map[string]aiid.ProviderConfig{provider.ID: provider}},
}}
got := conn.providersForLogin(login)[aiid.DefaultProvider]
- if got.BaseURL != "https://ai-services.beeper.localtest.me/proxy/openai/v1" {
+ if got.BaseURL != "https://ai-services.beeper.localtest.me" {
t.Fatalf("expected persisted default provider, got %#v", got)
}
}
@@ -358,7 +356,7 @@ func TestProvidersForLoginReadsProviderMap(t *testing.T) {
}},
}}
providers := conn.providersForLogin(login)
- if providers[aiid.DefaultProvider].BaseURL != "https://ai-services.beeper.com/proxy/openai/v1" {
+ if providers[aiid.DefaultProvider].BaseURL != "https://ai-services.beeper.com" {
t.Fatalf("default provider missing from provider map: %#v", providers)
}
if providers["custom"].APIKey != "secret-key" || providers["custom"].DefaultModel != "model-a" {
@@ -647,119 +645,51 @@ func TestProviderModelsParsesOptionalModelList(t *testing.T) {
}
}
-func TestFetchProviderModelsVerifiesAndBuildsModels(t *testing.T) {
- var gotAuth string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path != "/v1/models" {
- t.Fatalf("unexpected path %s", r.URL.Path)
- }
- gotAuth = r.Header.Get("Authorization")
- _, _ = w.Write([]byte(`{"data":[{"id":"model-a"},{"id":"model-b"},{"id":"model-a"}]}`))
- }))
- defer server.Close()
-
- models, err := fetchProviderModels(context.Background(), ai.ApiOpenAIResponses, "local", server.URL+"/v1", "key")
- if err != nil {
- t.Fatal(err)
- }
- if gotAuth != "Bearer key" {
- t.Fatalf("unexpected auth header %q", gotAuth)
- }
- if len(models) != 2 || models[0].ID != "model-a" || models[1].ID != "model-b" {
- t.Fatalf("unexpected models %#v", models)
- }
- if models[0].API != ai.ApiOpenAIResponses || models[0].Provider != "local" || models[0].BaseURL != server.URL+"/v1" {
- t.Fatalf("unexpected model route %#v", models[0])
- }
-}
-
-func TestFetchProviderModelsRespectsPublishedProviderRoutes(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`{"data":[
- {"id":"claude-sonnet-4-5","name":"Claude Sonnet 4.5","provider":{"id":"wpcom_anthropic","model_id":"claude-sonnet-4-5","api":"openai-responses"}},
- {"id":"gemini-2.5-flash-lite","name":"Gemini 2.5 Flash Lite","provider":{"id":"wpcom_vertex","model_id":"gemini-2.5-flash-lite","api":"openai-responses"}},
- {"id":"google/gemini-2.5-flash","name":"Gemini 2.5 Flash","provider":{"id":"wpcom_google","model_id":"gemini-2.5-flash","api":"openai-responses"}}
- ]}`))
- }))
- defer server.Close()
-
- models, err := fetchProviderModels(context.Background(), ai.ApiOpenAIResponses, "local", server.URL+"/proxy/openai/v1", "key")
- if err != nil {
- t.Fatal(err)
- }
- byID := map[string]ai.Model{}
- for _, model := range models {
- byID[model.ID] = model
- }
- if got := byID["claude-sonnet-4-5"]; got.API != ai.ApiAnthropicMessages || got.Provider != ai.ProviderAnthropic || got.BaseURL != server.URL+"/proxy/anthropic" {
- t.Fatalf("unexpected Anthropic route %#v", got)
+func TestCatalogRuntimeForCustomProviderUsesCustomBaseURL(t *testing.T) {
+ entry := aiServicesModelEntry{
+ ID: "z-ai/glm-4.5v",
+ Name: "GLM 4.5V",
+ Runtime: &struct {
+ API string `json:"api"`
+ Provider string `json:"provider"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ Endpoint string `json:"endpoint"`
+ Compat *aiServicesModelCompat `json:"compat"`
+ }{
+ API: string(ai.ApiOpenAICompletions),
+ Provider: string(ai.ProviderOpenRouter),
+ BaseURL: "/proxy/openrouter/v1",
+ Model: "z-ai/glm-4.5v",
+ },
}
- if got := byID["gemini-2.5-flash-lite"]; got.API != ai.ApiGoogleVertex || got.Provider != ai.ProviderGoogleVertex || got.BaseURL != server.URL+"/proxy/vertex" {
- t.Fatalf("unexpected Vertex route %#v", got)
+ provider := aiid.ProviderConfig{
+ ID: "openrouter",
+ API: ai.ApiOpenAICompletions,
+ Provider: ai.ProviderOpenRouter,
+ BaseURL: "https://openrouter.ai/api/v1",
}
- if got := byID["google/gemini-2.5-flash"]; got.API != ai.ApiGoogleVertex || got.Provider != ai.ProviderGoogleVertex || got.BaseURL != server.URL+"/proxy/vertex" {
- t.Fatalf("unexpected legacy Google route %#v", got)
+ model := entry.applyRuntime(ai.Model{
+ ID: entry.ID,
+ Name: entry.Name,
+ API: provider.API,
+ Provider: provider.Provider,
+ BaseURL: provider.BaseURL,
+ }, provider, false)
+ if model.API != ai.ApiOpenAICompletions || model.Provider != ai.ProviderOpenRouter || model.BaseURL != provider.BaseURL {
+ t.Fatalf("unexpected custom provider runtime %#v", model)
}
-}
-
-func TestFetchProviderModelsRejectsFailedVerification(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, "bad key", http.StatusUnauthorized)
- }))
- defer server.Close()
-
- if _, err := fetchProviderModels(context.Background(), ai.ApiOpenAICompletions, "local", server.URL, "bad"); err == nil {
- t.Fatalf("expected failed verification to return an error")
+ if model.Compat["runtime_model"] != "z-ai/glm-4.5v" || model.Compat["runtime_provider"] != string(ai.ProviderOpenRouter) {
+ t.Fatalf("expected runtime metadata, got %#v", model.Compat)
}
}
-func TestCustomProviderLoginStagesAPIConfigAndDefaultModel(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`{"data":[{"id":"gpt-test"},{"id":"gpt-other"}]}`))
- }))
- defer server.Close()
-
+func TestCustomProviderLoginConfigStep(t *testing.T) {
login := &CustomProviderLogin{config: providerLoginConfig{API: ai.ApiOpenAICompletions}}
step := login.providerConfigStep()
if step.StepID != loginStepProviderConfig || len(step.UserInputParams.Fields) != 3 {
t.Fatalf("unexpected config step %#v", step)
}
-
- step, err := login.submitProviderConfig(context.Background(), map[string]string{
- "provider_id": "local-ai",
- "base_url": server.URL,
- "api_key": "key",
- })
- if err != nil {
- t.Fatal(err)
- }
- if step.StepID != loginStepProviderDefault {
- t.Fatalf("expected default model step, got %#v", step)
- }
- field := step.UserInputParams.Fields[0]
- if field.Type != bridgev2.LoginInputFieldTypeSelect || field.DefaultValue != "gpt-test" || len(field.Options) != 2 {
- t.Fatalf("unexpected default model field %#v", field)
- }
- if login.config.ProviderID != "local-ai" || login.config.API != ai.ApiOpenAICompletions || len(login.config.Models) != 2 {
- t.Fatalf("login config was not retained: %#v", login.config)
- }
-}
-
-func TestCustomProviderLoginRejectsEmptyModelList(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- _, _ = w.Write([]byte(`{"data":[]}`))
- }))
- defer server.Close()
-
- login := &CustomProviderLogin{config: providerLoginConfig{API: ai.ApiOpenAICompletions}}
- _, err := login.submitProviderConfig(context.Background(), map[string]string{
- "provider_id": "local-ai",
- "base_url": server.URL,
- "api_key": "key",
- })
- if err == nil || !strings.Contains(err.Error(), "no models") {
- t.Fatalf("expected empty model list error, got %v", err)
- }
}
func TestModelForProviderAppliesRouteBaseURLToDefaultModel(t *testing.T) {
@@ -768,13 +698,13 @@ func TestModelForProviderAppliesRouteBaseURLToDefaultModel(t *testing.T) {
ID: aiid.DefaultProvider,
API: ai.ApiOpenAIResponses,
Provider: ai.ProviderOpenAI,
- BaseURL: "https://ai-services.beeper.com/proxy/openai/v1/responses",
+ BaseURL: "https://ai-services.beeper.com",
}
model := conn.ModelForProvider(provider, "gpt-5.5")
if model.Provider != ai.ProviderOpenAI || model.ID != "gpt-5.5" {
t.Fatalf("expected AI Services model, got %#v", model)
}
- if model.BaseURL != "https://ai-services.beeper.com/proxy/openai/v1" {
+ if model.BaseURL != "https://ai-services.beeper.com" {
t.Fatalf("expected route base URL override, got %q", model.BaseURL)
}
if model.ContextWindow != 128000 || model.MaxTokens != 32000 {
@@ -782,7 +712,7 @@ func TestModelForProviderAppliesRouteBaseURLToDefaultModel(t *testing.T) {
}
}
-func TestResolveProviderRequiresListedModelWhenModelListExists(t *testing.T) {
+func TestResolveProviderRejectsUnknownConfiguredModel(t *testing.T) {
conn := &Connector{}
provider := aiid.ProviderConfig{
ID: "custom",
@@ -795,11 +725,11 @@ func TestResolveProviderRequiresListedModelWhenModelListExists(t *testing.T) {
ID: "login",
Metadata: &aiid.UserLoginMetadata{Providers: map[string]aiid.ProviderConfig{provider.ID: provider}},
}}
- _, _, err := conn.ResolveProvider(context.Background(), login, RoomConfig{ProviderID: "custom", ModelID: "missing"})
+ _, modelID, err := conn.ResolveProvider(context.Background(), login, RoomConfig{ProviderID: "custom", ModelID: "missing"})
if err == nil {
- t.Fatal("expected missing model to be rejected")
+ t.Fatalf("expected missing model to fail, got model ID %q", modelID)
}
- _, modelID, err := conn.ResolveProvider(context.Background(), login, RoomConfig{ProviderID: "custom", ModelID: "allowed"})
+ _, modelID, err = conn.ResolveProvider(context.Background(), login, RoomConfig{ProviderID: "custom", ModelID: "allowed"})
if err != nil {
t.Fatal(err)
}
@@ -821,10 +751,7 @@ func TestValidateReasoningLevelRejectsUnsupportedPair(t *testing.T) {
func TestValidateReasoningLevelAcceptsOffForReasoningModel(t *testing.T) {
client := &Client{Main: &Connector{Config: Config{DefaultReasoningLevel: "off"}}}
- model, ok := ai.GetModel(ai.ProviderOpenAI, "gpt-5.4")
- if !ok {
- t.Fatal("expected generated gpt-5.4 model")
- }
+ model := ai.Model{ID: "reasoning", Reasoning: true}
if err := client.validateReasoningLevel(model, RoomConfig{}); err != nil {
t.Fatalf("expected default off reasoning to be accepted: %v", err)
}
@@ -850,6 +777,31 @@ func TestDefaultReasoningLevelClampsForMandatoryReasoningModel(t *testing.T) {
}
}
+func TestValidateReasoningModeRejectsUnsupportedPair(t *testing.T) {
+ client := &Client{Main: &Connector{Config: Config{DefaultReasoningLevel: "off"}}}
+ model := ai.Model{ID: "plain", Input: []string{"text"}}
+ if err := client.validateReasoningMode(model, RoomConfig{ReasoningMode: "adaptive"}); err == nil {
+ t.Fatalf("expected unsupported reasoning mode to fail")
+ }
+ if err := client.validateReasoningMode(model, RoomConfig{ReasoningMode: "default"}); err != nil {
+ t.Fatalf("expected default reasoning mode to be accepted: %v", err)
+ }
+ if err := client.validateReasoningMode(model, RoomConfig{ReasoningMode: "bad"}); err == nil {
+ t.Fatalf("expected invalid reasoning mode to fail")
+ }
+}
+
+func TestReasoningModeDefaultsFromModelCatalog(t *testing.T) {
+ client := &Client{Main: &Connector{Config: Config{DefaultReasoningLevel: "off"}}}
+ model := ai.Model{ID: "anthropic/claude-opus-4.8", ReasoningMode: ai.ModelReasoningModeAdaptive}
+ if got := client.reasoningModeForModel(model, RoomConfig{}); got != "adaptive" {
+ t.Fatalf("expected catalog reasoning mode, got %q", got)
+ }
+ if err := client.validateReasoningMode(model, RoomConfig{ReasoningMode: "adaptive"}); err != nil {
+ t.Fatalf("expected adaptive reasoning mode to be accepted: %v", err)
+ }
+}
+
func TestNormalizeProviderModelDoesNotInheritDefaultProviderCatalogMetadata(t *testing.T) {
model := normalizeProviderModel(ai.Model{
ID: "minimax/minimax-m2.7",
@@ -858,7 +810,7 @@ func TestNormalizeProviderModelDoesNotInheritDefaultProviderCatalogMetadata(t *t
ID: aiid.DefaultProvider,
Provider: ai.ProviderOpenAI,
API: ai.ApiOpenAIResponses,
- BaseURL: "https://ai-services.test/proxy/openrouter/v1",
+ BaseURL: "https://ai-services.test",
})
if model.Reasoning || model.DefaultThinkingLevel != "" || len(model.ThinkingLevelMap) != 0 {
t.Fatalf("expected default provider model to rely only on AI Services metadata, got %#v", model)
@@ -870,8 +822,12 @@ func TestNormalizeProviderModelDoesNotInheritDefaultProviderCatalogMetadata(t *t
func TestNormalizeProviderModelInheritsCustomProviderCatalogReasoningMetadata(t *testing.T) {
model := normalizeProviderModel(ai.Model{
- ID: "gpt-5.5",
- Provider: ai.ProviderOpenAI,
+ ID: "gpt-5.5",
+ Provider: ai.ProviderOpenAI,
+ Reasoning: true,
+ ThinkingLevelMap: map[ai.ModelThinkingLevel]*string{
+ ai.ModelThinkingLevelOff: nil,
+ },
}, aiid.ProviderConfig{
ID: "custom-openai",
Provider: ai.ProviderOpenAI,
@@ -879,7 +835,7 @@ func TestNormalizeProviderModelInheritsCustomProviderCatalogReasoningMetadata(t
BaseURL: "https://custom.test/v1",
})
if !model.Reasoning || len(model.ThinkingLevelMap) == 0 {
- t.Fatalf("expected custom provider model to inherit local catalog reasoning metadata, got %#v", model)
+ t.Fatalf("expected custom provider model to preserve catalog reasoning metadata, got %#v", model)
}
if roomThinkingLevelSupported(model, ai.ModelThinkingLevelOff) {
t.Fatalf("expected normalized GPT-5.5 custom provider model to reject off reasoning")
diff --git a/pkg/connector/room_state.go b/pkg/connector/room_state.go
index 4e4f08e1..7d4f6924 100644
--- a/pkg/connector/room_state.go
+++ b/pkg/connector/room_state.go
@@ -26,14 +26,18 @@ type RoomConfig struct {
ModelID string
AdditionalPrompt string
ThinkingLevel string
+ ReasoningMode string
DisabledTools []string
+ SearchMode string
+ FetchMode string
- modelStatePresent bool
- modelStateModel string
- modelStateName string
- modelStateReason string
- modelStateEventID string
- promptStateEventID string
+ modelStatePresent bool
+ modelStateModel string
+ modelStateName string
+ modelStateReason string
+ modelStateReasoningMode string
+ modelStateEventID string
+ promptStateEventID string
}
func (c *Connector) aiRoomStateStore() AIRoomStateStore {
@@ -62,11 +66,15 @@ func (s AIRoomStateStore) ReadConfig(ctx context.Context, roomID id.RoomID) (Roo
config.modelStateModel = firstString(raw, "model")
config.modelStateName = firstString(raw, "name")
config.modelStateReason = firstString(raw, "reasoning")
+ config.modelStateReasoningMode = firstString(raw, "reasoning_mode")
config.modelStateEventID = eventID
applyRoomModelConfig(&config, raw)
if _, ok := raw["reasoning"]; !ok {
config.ThinkingLevel = ""
}
+ if _, ok := raw["reasoning_mode"]; !ok {
+ config.ReasoningMode = ""
+ }
stateEventIDs = append(stateEventIDs, eventID)
}
if raw, eventID, err := s.readRoomState(ctx, reader, roomID, aiid.RoomPromptType); err != nil {
@@ -80,6 +88,16 @@ func (s AIRoomStateStore) ReadConfig(ctx context.Context, roomID id.RoomID) (Roo
return RoomConfig{}, "", err
} else if raw != nil {
config.DisabledTools = stringSlice(raw["disabled"])
+ if _, ok := raw["search"]; ok {
+ config.SearchMode = normalizedToolMode(firstString(raw, "search"), defaultSearchMode)
+ } else {
+ config.SearchMode = searchModeFromDisabled(config.DisabledTools)
+ }
+ if _, ok := raw["fetch"]; ok {
+ config.FetchMode = normalizedToolMode(firstString(raw, "fetch"), defaultFetchMode)
+ } else {
+ config.FetchMode = fetchModeFromDisabled(config.DisabledTools)
+ }
stateEventIDs = append(stateEventIDs, eventID)
}
return config, strings.Join(stateEventIDs, ","), nil
@@ -121,20 +139,14 @@ func (c *Connector) ResolveProvider(ctx context.Context, login *bridgev2.UserLog
if resolvedModelID, ok := resolveProviderModelID(provider, modelID); ok {
return provider, resolvedModelID, nil
}
- if !providerAllowsModel(provider, modelID) {
- return aiid.ProviderConfig{}, "", fmt.Errorf("model %s is not available for provider %s", modelID, providerID)
- }
- return provider, modelID, nil
+ return aiid.ProviderConfig{}, "", fmt.Errorf("provider %s does not offer model %s", providerID, modelID)
}
func providerAllowsModel(provider aiid.ProviderConfig, modelID string) bool {
if _, ok := resolveProviderModelID(provider, modelID); ok {
return true
}
- if len(provider.Models) > 0 {
- return false
- }
- return strings.TrimSpace(modelID) != ""
+ return false
}
func providerHasModel(provider aiid.ProviderConfig, modelID string) bool {
@@ -210,6 +222,9 @@ func applyRoomModelConfig(config *RoomConfig, raw map[string]any) {
if reasoning := firstString(raw, "reasoning"); reasoning != "" {
config.ThinkingLevel = reasoning
}
+ if reasoningMode := firstString(raw, "reasoning_mode"); reasoningMode != "" {
+ config.ReasoningMode = reasoningMode
+ }
}
func splitModelRef(model string) (providerID string, modelID string) {
diff --git a/pkg/connector/room_state_events.go b/pkg/connector/room_state_events.go
new file mode 100644
index 00000000..5e923c03
--- /dev/null
+++ b/pkg/connector/room_state_events.go
@@ -0,0 +1,40 @@
+package connector
+
+import (
+ "reflect"
+
+ "maunium.net/go/mautrix/event"
+
+ "github.com/beeper/ai-bridge/pkg/aiid"
+)
+
+type aiRoomModelStateEventContent struct {
+ Model string `json:"model,omitempty"`
+ Name string `json:"name,omitempty"`
+ Reasoning string `json:"reasoning,omitempty"`
+ ReasoningMode string `json:"reasoning_mode,omitempty"`
+}
+
+type aiRoomPromptStateEventContent struct {
+ Prompt string `json:"prompt,omitempty"`
+}
+
+type aiRoomToolsStateEventContent struct {
+ Disabled []string `json:"disabled,omitempty"`
+ Search string `json:"search,omitempty"`
+ Fetch string `json:"fetch,omitempty"`
+}
+
+func init() {
+ registerAIRoomStateEventContentTypes()
+}
+
+func registerAIRoomStateEventContentTypes() {
+ event.TypeMap[aiRoomStateEventType(aiid.RoomModelType)] = reflect.TypeOf(aiRoomModelStateEventContent{})
+ event.TypeMap[aiRoomStateEventType(aiid.RoomPromptType)] = reflect.TypeOf(aiRoomPromptStateEventContent{})
+ event.TypeMap[aiRoomStateEventType(aiid.RoomToolsType)] = reflect.TypeOf(aiRoomToolsStateEventContent{})
+}
+
+func aiRoomStateEventType(stateType string) event.Type {
+ return event.Type{Type: stateType, Class: event.StateEventType}
+}
diff --git a/pkg/connector/room_state_test.go b/pkg/connector/room_state_test.go
index 8bf0e64b..b5a4eb21 100644
--- a/pkg/connector/room_state_test.go
+++ b/pkg/connector/room_state_test.go
@@ -1,29 +1,108 @@
package connector
import (
+ "reflect"
"testing"
+ "maunium.net/go/mautrix/event"
+
ai "github.com/beeper/ai-bridge/pkg/ai"
+ "github.com/beeper/ai-bridge/pkg/aiid"
)
func TestApplyRoomModelConfigUsesProviderModelRefAndReasoning(t *testing.T) {
config := RoomConfig{}
- applyRoomModelConfig(&config, map[string]any{"model": "openrouter/openai/gpt-5", "reasoning": "high"})
- if config.ProviderID != "openrouter" || config.ModelID != "openai/gpt-5" || config.ThinkingLevel != "high" {
+ applyRoomModelConfig(&config, map[string]any{"model": "openrouter/openai/gpt-5", "reasoning": "high", "reasoning_mode": "adaptive"})
+ if config.ProviderID != "openrouter" || config.ModelID != "openai/gpt-5" || config.ThinkingLevel != "high" || config.ReasoningMode != "adaptive" {
t.Fatalf("unexpected model config %#v", config)
}
}
func TestRoomModelStateContentIncludesCatalogName(t *testing.T) {
- content := roomModelStateContent(ai.Model{ID: "openai/gpt-5.5", Name: "GPT-5.5"}, "beeper/openai/gpt-5.5", "medium")
- if content["model"] != "beeper/openai/gpt-5.5" || content["name"] != "GPT-5.5" || content["reasoning"] != "medium" {
+ content := roomModelStateContent(ai.Model{ID: "openai/gpt-5.5", Name: "GPT-5.5"}, "beeper/openai/gpt-5.5", "medium", "adaptive")
+ if content["model"] != "beeper/openai/gpt-5.5" || content["name"] != "GPT-5.5" || content["reasoning"] != "medium" || content["reasoning_mode"] != "adaptive" {
t.Fatalf("unexpected room model content %#v", content)
}
}
+func TestRoomModelStateContentOmitsEmptyReasoningMode(t *testing.T) {
+ content := roomModelStateContent(ai.Model{ID: "openai/gpt-5.5"}, "beeper/openai/gpt-5.5", "medium", "")
+ if _, ok := content["reasoning_mode"]; ok {
+ t.Fatalf("unexpected empty reasoning mode in room model content %#v", content)
+ }
+}
+
+func TestAIRoomStateEventContentTypesParse(t *testing.T) {
+ tests := []struct {
+ name string
+ evtType string
+ raw []byte
+ want any
+ }{
+ {
+ name: "model",
+ evtType: aiid.RoomModelType,
+ raw: []byte(`{"model":"beeper/openai/gpt-5.5","name":"GPT-5.5","reasoning":"medium","reasoning_mode":"adaptive"}`),
+ want: &aiRoomModelStateEventContent{
+ Model: "beeper/openai/gpt-5.5",
+ Name: "GPT-5.5",
+ Reasoning: "medium",
+ ReasoningMode: "adaptive",
+ },
+ },
+ {
+ name: "prompt",
+ evtType: aiid.RoomPromptType,
+ raw: []byte(`{"prompt":"be terse"}`),
+ want: &aiRoomPromptStateEventContent{Prompt: "be terse"},
+ },
+ {
+ name: "tools",
+ evtType: aiid.RoomToolsType,
+ raw: []byte(`{"disabled":["web_search"],"search":"native","fetch":"beeper"}`),
+ want: &aiRoomToolsStateEventContent{Disabled: []string{"web_search"}, Search: "native", Fetch: "beeper"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ content := event.Content{VeryRaw: tt.raw}
+ if err := content.ParseRaw(aiRoomStateEventType(tt.evtType)); err != nil {
+ t.Fatalf("expected AI room state to parse: %v", err)
+ }
+ if got := content.Parsed; !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("unexpected parsed content %#v", got)
+ }
+ })
+ }
+}
+
func TestStringSliceDeduplicatesDisabledTools(t *testing.T) {
got := stringSlice([]any{"web_search", "web_search", "", "fetch"})
if len(got) != 2 || got[0] != "web_search" || got[1] != "fetch" {
t.Fatalf("unexpected disabled tools %#v", got)
}
}
+
+func TestRoomToolModesDefaultAndLegacyDisabled(t *testing.T) {
+ if got := roomSearchMode(RoomConfig{}); got != toolModeBeeper {
+ t.Fatalf("default search mode = %q", got)
+ }
+ if got := roomFetchMode(RoomConfig{}); got != toolModeBeeper {
+ t.Fatalf("default fetch mode = %q", got)
+ }
+ if got := roomSearchMode(RoomConfig{DisabledTools: []string{"web_search"}}); got != toolModeOff {
+ t.Fatalf("legacy disabled web_search should turn search off, got %q", got)
+ }
+ if got := roomFetchMode(RoomConfig{DisabledTools: []string{"fetch"}}); got != toolModeOff {
+ t.Fatalf("disabled fetch should turn fetch off, got %q", got)
+ }
+ if got := roomSearchMode(RoomConfig{SearchMode: "native", DisabledTools: []string{"web_search"}}); got != toolModeNative {
+ t.Fatalf("explicit search mode should win over disabled list, got %q", got)
+ }
+ if got := roomFetchMode(RoomConfig{FetchMode: "native", DisabledTools: []string{"fetch"}}); got != toolModeNative {
+ t.Fatalf("explicit fetch mode should win over disabled list, got %q", got)
+ }
+ if got := roomFetchMode(RoomConfig{FetchMode: "bad"}); got != defaultFetchMode {
+ t.Fatalf("invalid fetch mode should fall back, got %q", got)
+ }
+}
diff --git a/pkg/connector/slash_commands.go b/pkg/connector/slash_commands.go
index 997e7599..e80615bd 100644
--- a/pkg/connector/slash_commands.go
+++ b/pkg/connector/slash_commands.go
@@ -81,6 +81,14 @@ func aiSlashCommandDefinitions() []aiSlashCommandDefinition {
noticeErrors: true,
run: runReasoningCommand,
},
+ {
+ name: "reasoning-mode",
+ usage: "/reasoning-mode [default|adaptive]",
+ description: "Show or set the reasoning mode for this room when the selected model supports it.",
+ needsRoomConfig: true,
+ noticeErrors: true,
+ run: runReasoningModeCommand,
+ },
{
name: "system-prompt",
usage: "/system-prompt [prompt|clear]",
@@ -88,6 +96,22 @@ func aiSlashCommandDefinitions() []aiSlashCommandDefinition {
needsRoomConfig: true,
run: runSystemPromptCommand,
},
+ {
+ name: "search",
+ usage: "/search [off|beeper|native]",
+ description: "Show or set web search mode for this room.",
+ needsRoomConfig: true,
+ noticeErrors: true,
+ run: runSearchModeCommand,
+ },
+ {
+ name: "fetch",
+ usage: "/fetch [off|beeper|native]",
+ description: "Show or set URL fetch mode for this room.",
+ needsRoomConfig: true,
+ noticeErrors: true,
+ run: runFetchModeCommand,
+ },
{
name: "compact",
usage: "/compact [instructions]",
diff --git a/pkg/connector/slash_commands_limits.go b/pkg/connector/slash_commands_limits.go
index 537b658d..bd6d6ae3 100644
--- a/pkg/connector/slash_commands_limits.go
+++ b/pkg/connector/slash_commands_limits.go
@@ -56,10 +56,10 @@ func runLimitsCommand(cl *Client, ctx context.Context, _ *bridgev2.Portal, _ Roo
return err
}
now := time.Now()
+ text := formatLimitsCommandInfo(limits, now)
if raw {
- return responder.Reply(ctx, formatRawLimitsCommandInfo(limits, now))
+ text = formatRawLimitsCommandInfo(limits, now)
}
- text := formatLimitsCommandInfo(limits, now)
if aiResponder, ok := responder.(aiCommandAIResponder); ok {
return aiResponder.ReplyAI(ctx, text)
}
@@ -118,12 +118,11 @@ func (cl *Client) defaultAIProviderForLimits() (aiid.ProviderConfig, error) {
return provider, nil
}
-func aiServicesLimitsURL(proxyBaseURL string) (string, error) {
- parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(proxyBaseURL), "/"))
+func aiServicesLimitsURL(baseURL string) (string, error) {
+ parsed, err := url.Parse(strings.TrimRight(normalizeResponsesBaseURL(baseURL), "/"))
if err != nil {
return "", err
}
- parsed.Path = trimAIProxyProviderPath(parsed.Path)
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/limits"
parsed.RawQuery = ""
parsed.Fragment = ""
@@ -132,25 +131,23 @@ func aiServicesLimitsURL(proxyBaseURL string) (string, error) {
func formatLimitsCommandInfo(limits aiServicesLimitsResponse, now time.Time) string {
var text strings.Builder
- text.WriteString("AI limits\n\n")
- appendLimitSection(&text, "Models", limits.Windows.LLM, now)
- text.WriteString("\n")
- appendLimitSection(&text, "Web Search", limits.Windows.WebTools, now)
- if !emptyLimitWindows(limits.Windows.AudioTranscriptions) {
- text.WriteString("\n")
- appendLimitSectionWithUsedFormatter(&text, "Transcription", limits.Windows.AudioTranscriptions, now, formatLimitUsedMinutes)
- }
- if !emptyLimitWindows(limits.Windows.AudioGeneration) {
- text.WriteString("\n")
- appendLimitSectionWithUsedFormatter(&text, "Audio Generation", limits.Windows.AudioGeneration, now, formatLimitUsedCharacters)
+ text.WriteString("# AI limits\n\n")
+ appendLimitSectionIfReported(&text, "Models", limits.Windows.LLM, now)
+ appendLimitSectionIfReported(&text, "Web Search", limits.Windows.WebTools, now)
+ appendLimitSectionIfReported(&text, "Transcription", limits.Windows.AudioTranscriptions, now)
+ appendLimitSectionIfReported(&text, "Audio Generation", limits.Windows.AudioGeneration, now)
+ if strings.TrimSpace(text.String()) == "# AI limits" {
+ text.WriteString("No limits reported.\n")
}
return text.String()
}
func formatRawLimitsCommandInfo(limits aiServicesLimitsResponse, now time.Time) string {
var text strings.Builder
- text.WriteString("AI limits raw:")
- for _, category := range limitCategories(limits) {
+ for idx, category := range limitCategories(limits) {
+ if idx > 0 {
+ text.WriteString("\n")
+ }
appendRawLimitCategory(&text, category.label, category.windows, now)
}
return text.String()
@@ -162,23 +159,29 @@ type limitCategory struct {
}
func limitCategories(limits aiServicesLimitsResponse) []limitCategory {
- categories := []limitCategory{{label: "LLM tokens", windows: limits.Windows.LLM}}
- if !emptyLimitWindows(limits.Windows.WebTools) {
- categories = append(categories, limitCategory{label: "Web tools", windows: limits.Windows.WebTools})
- }
- if !emptyLimitWindows(limits.Windows.AudioTranscriptions) {
- categories = append(categories, limitCategory{label: "Audio transcription seconds", windows: limits.Windows.AudioTranscriptions})
- }
- if !emptyLimitWindows(limits.Windows.AudioGeneration) {
- categories = append(categories, limitCategory{label: "Audio generation characters", windows: limits.Windows.AudioGeneration})
+ return []limitCategory{
+ {label: "LLM tokens", windows: limits.Windows.LLM},
+ {label: "Web tools", windows: limits.Windows.WebTools},
+ {label: "Audio transcription seconds", windows: limits.Windows.AudioTranscriptions},
+ {label: "Audio generation characters", windows: limits.Windows.AudioGeneration},
}
- return categories
}
func appendLimitSection(text *strings.Builder, label string, windows aiServicesLimitWindows, now time.Time) {
appendLimitSectionWithUsedFormatter(text, label, windows, now, formatLimitUsed)
}
+func appendLimitSectionIfReported(text *strings.Builder, label string, windows aiServicesLimitWindows, now time.Time) {
+ if emptyLimitWindows(windows) {
+ return
+ }
+ if text.Len() > 0 && !strings.HasSuffix(text.String(), "\n\n") {
+ text.WriteString("\n")
+ }
+ appendLimitSection(text, label, windows, now)
+ text.WriteString("\n")
+}
+
func appendLimitSectionWithUsedFormatter(text *strings.Builder, label string, windows aiServicesLimitWindows, now time.Time, formatUsed func(aiServicesLimitWindow) string) {
fmt.Fprintf(text, "## %s\n\n", label)
if emptyLimitWindows(windows) {
@@ -227,31 +230,6 @@ func formatLimitUsed(window aiServicesLimitWindow) string {
return fmt.Sprintf("`%s / %s`", formatInt(window.Used), formatInt(window.Limit))
}
-func formatLimitUsedMinutes(window aiServicesLimitWindow) string {
- if window.Limit == 0 && window.Used == 0 && window.Remaining == 0 {
- return "Not reported"
- }
- if window.Limit < 0 {
- return fmt.Sprintf("`%s` used", formatSecondsAsMinutes(window.Used))
- }
- return fmt.Sprintf("`%s / %s`", formatSecondsAsMinutes(window.Used), formatSecondsAsMinutes(window.Limit))
-}
-
-func formatLimitUsedCharacters(window aiServicesLimitWindow) string {
- if window.Limit == 0 && window.Used == 0 && window.Remaining == 0 {
- return "Not reported"
- }
- if window.Limit < 0 {
- return fmt.Sprintf("`%s chars` used", formatInt(window.Used))
- }
- return fmt.Sprintf("`%s / %s chars`", formatInt(window.Used), formatInt(window.Limit))
-}
-
-func formatSecondsAsMinutes(seconds int64) string {
- minutes := (seconds + 59) / 60
- return fmt.Sprintf("%s %s", formatInt(minutes), pluralize("minute", int(minutes)))
-}
-
func formatLimitReset(window aiServicesLimitWindow, now time.Time) string {
if window.ResetAtMS <= 0 {
return "unknown"
@@ -260,7 +238,9 @@ func formatLimitReset(window aiServicesLimitWindow, now time.Time) string {
}
func appendRawLimitCategory(text *strings.Builder, label string, windows aiServicesLimitWindows, now time.Time) {
- fmt.Fprintf(text, "\n\n%s:", label)
+ fmt.Fprintf(text, "## %s\n\n", label)
+ text.WriteString("| Window | Left | Used | Limit | Remaining | Reset |\n")
+ text.WriteString("| --- | ---: | ---: | ---: | ---: | --- |\n")
appendRawLimitWindow(text, "Day", windows.Day, now)
appendRawLimitWindow(text, "Week", windows.Week, now)
appendRawLimitWindow(text, "Month", windows.Month, now)
@@ -269,17 +249,22 @@ func appendRawLimitCategory(text *strings.Builder, label string, windows aiServi
func appendRawLimitWindow(text *strings.Builder, label string, window aiServicesLimitWindow, now time.Time) {
fmt.Fprintf(
text,
- "\n- %s: percentage_left=`%d`, limit=`%s`, used=`%s`, remaining=`%s`",
+ "| %s | `%d%%` | `%s` | `%s` | `%s` | %s |\n",
label,
window.PercentageLeft,
- formatInt(window.Limit),
formatInt(window.Used),
+ formatInt(window.Limit),
formatInt(window.Remaining),
+ formatRawLimitReset(window, now),
)
+}
+
+func formatRawLimitReset(window aiServicesLimitWindow, now time.Time) string {
if window.ResetAtMS > 0 {
resetAt := time.UnixMilli(window.ResetAtMS)
- fmt.Fprintf(text, ", reset_at=`%d` (`%s`, in %s)", window.ResetAtMS, resetAt.UTC().Format(time.RFC3339), formatResetIn(resetAt, now))
+ return fmt.Sprintf("`%d` (`%s`, in %s)", window.ResetAtMS, resetAt.UTC().Format(time.RFC3339), formatResetIn(resetAt, now))
}
+ return "unknown"
}
func sharedResetAt(categories []limitCategory) (time.Time, bool) {
diff --git a/pkg/connector/slash_commands_model.go b/pkg/connector/slash_commands_model.go
index 3b8ca414..5836778c 100644
--- a/pkg/connector/slash_commands_model.go
+++ b/pkg/connector/slash_commands_model.go
@@ -21,13 +21,17 @@ func runReasoningCommand(cl *Client, ctx context.Context, portal *bridgev2.Porta
return cl.applyReasoningCommand(ctx, portal, roomConfig, arg, responder)
}
+func runReasoningModeCommand(cl *Client, ctx context.Context, portal *bridgev2.Portal, roomConfig RoomConfig, arg string, responder aiCommandResponder) error {
+ return cl.applyReasoningModeCommand(ctx, portal, roomConfig, arg, responder)
+}
+
func (cl *Client) applyModelCommand(ctx context.Context, portal *bridgev2.Portal, current RoomConfig, requested string, responder aiCommandResponder) error {
if strings.TrimSpace(requested) == "" {
provider, model, canonical, err := cl.resolveCanonicalRoomModel(ctx, current)
if err != nil {
return fmt.Errorf("AI room settings rejected: %v", err)
}
- return responder.Reply(ctx, cl.modelStatusText(canonical, cl.reasoningLevelForModel(model, current), provider))
+ return responder.Reply(ctx, cl.modelStatusText(canonical, cl.reasoningLevelForModel(model, current), cl.reasoningModeForModel(model, current), provider))
}
target := current
providerID, modelID := splitModelRef(requested)
@@ -36,6 +40,7 @@ func (cl *Client) applyModelCommand(ctx context.Context, portal *bridgev2.Portal
}
target.ProviderID = providerID
target.ModelID = modelID
+ target.ReasoningMode = ""
provider, model, canonical, err := cl.resolveCanonicalRoomModel(ctx, target)
if err != nil {
return fmt.Errorf("AI room settings rejected: %v", err)
@@ -43,12 +48,16 @@ func (cl *Client) applyModelCommand(ctx context.Context, portal *bridgev2.Portal
if err = cl.validateReasoningLevel(model, target); err != nil {
return fmt.Errorf("AI room settings rejected: %v", err)
}
+ if err = cl.validateReasoningMode(model, target); err != nil {
+ return fmt.Errorf("AI room settings rejected: %v", err)
+ }
target.ThinkingLevel = cl.reasoningLevelForModel(model, target)
- if _, err = cl.writeRoomModelState(ctx, portal, provider, model, canonical, target.ThinkingLevel); err != nil {
+ target.ReasoningMode = cl.reasoningModeForModel(model, target)
+ if _, err = cl.writeRoomModelState(ctx, portal, provider, model, canonical, target.ThinkingLevel, target.ReasoningMode); err != nil {
return err
}
cl.refreshRoomCapabilities(ctx, portal)
- return responder.Reply(ctx, fmt.Sprintf("Model set to `%s`. Current reasoning is `%s`.", canonical, target.ThinkingLevel))
+ return responder.Reply(ctx, fmt.Sprintf("Model set to `%s`. %s%s", canonical, reasoningSettingSentence(target.ThinkingLevel, canonical, model), reasoningModeSentence(target.ReasoningMode)))
}
func (cl *Client) applyReasoningCommand(ctx context.Context, portal *bridgev2.Portal, current RoomConfig, requested string, responder aiCommandResponder) error {
@@ -72,11 +81,51 @@ func (cl *Client) applyReasoningCommand(ctx context.Context, portal *bridgev2.Po
if err = cl.validateReasoningLevel(model, target); err != nil {
return fmt.Errorf("AI room settings rejected: %v", err)
}
- if _, err = cl.writeRoomModelState(ctx, portal, provider, model, canonical, reasoning); err != nil {
+ if err = cl.validateReasoningMode(model, target); err != nil {
+ return fmt.Errorf("AI room settings rejected: %v", err)
+ }
+ target.ReasoningMode = cl.reasoningModeForModel(model, target)
+ if _, err = cl.writeRoomModelState(ctx, portal, provider, model, canonical, reasoning, target.ReasoningMode); err != nil {
+ return err
+ }
+ cl.refreshRoomCapabilities(ctx, portal)
+ return responder.Reply(ctx, fmt.Sprintf("%s's reasoning is now set to `%s`.", reasoningStatusModelName(model, canonical), reasoning))
+}
+
+func (cl *Client) applyReasoningModeCommand(ctx context.Context, portal *bridgev2.Portal, current RoomConfig, requested string, responder aiCommandResponder) error {
+ provider, model, canonical, err := cl.resolveCanonicalRoomModel(ctx, current)
+ if err != nil {
+ return fmt.Errorf("AI room settings rejected: %v", err)
+ }
+ if strings.TrimSpace(requested) == "" {
+ return responder.Reply(ctx, reasoningModeStatusText(cl.reasoningModeForModel(model, current), canonical, model))
+ }
+ mode := strings.ToLower(strings.TrimSpace(requested))
+ if !validRoomReasoningMode(mode) {
+ return fmt.Errorf("AI room settings rejected: reasoning mode %q is invalid", requested)
+ }
+ target := current
+ if mode == "default" {
+ target.ReasoningMode = ""
+ } else {
+ target.ReasoningMode = mode
+ }
+ if err = cl.validateReasoningLevel(model, target); err != nil {
+ return fmt.Errorf("AI room settings rejected: %v", err)
+ }
+ if err = cl.validateReasoningMode(model, target); err != nil {
+ return fmt.Errorf("AI room settings rejected: %v", err)
+ }
+ target.ThinkingLevel = cl.reasoningLevelForModel(model, target)
+ target.ReasoningMode = cl.reasoningModeForModel(model, target)
+ if _, err = cl.writeRoomModelState(ctx, portal, provider, model, canonical, target.ThinkingLevel, target.ReasoningMode); err != nil {
return err
}
cl.refreshRoomCapabilities(ctx, portal)
- return responder.Reply(ctx, fmt.Sprintf("Reasoning set to `%s` for `%s`.", reasoning, canonical))
+ if mode == "default" {
+ return responder.Reply(ctx, fmt.Sprintf("Reasoning mode reset to `default` for `%s`.%s", canonical, reasoningModeSentence(target.ReasoningMode)))
+ }
+ return responder.Reply(ctx, fmt.Sprintf("Reasoning mode set to `%s` for `%s`.", target.ReasoningMode, canonical))
}
func displayReasoningLevel(level string) string {
@@ -87,7 +136,48 @@ func displayReasoningLevel(level string) string {
}
func reasoningStatusText(current string, canonicalModel string, model ai.Model) string {
- return fmt.Sprintf("Current reasoning is `%s` for `%s`. Options: %s.", displayReasoningLevel(current), canonicalModel, reasoningOptionsText(model))
+ return reasoningSettingSentence(current, canonicalModel, model)
+}
+
+func reasoningSettingSentence(current string, canonicalModel string, model ai.Model) string {
+ levels := ai.GetSupportedThinkingLevels(model)
+ name := reasoningStatusModelName(model, canonicalModel)
+ if len(levels) == 1 {
+ currentLevel := displayReasoningLevel(current)
+ if current == "" {
+ currentLevel = string(levels[0])
+ }
+ if levels[0] == ai.ModelThinkingLevelOff {
+ return fmt.Sprintf("%s doesn't support reasoning.", name)
+ }
+ return fmt.Sprintf("%s's reasoning is set to `%s` and it doesn't support changing reasoning settings.", name, currentLevel)
+ }
+ return fmt.Sprintf("%s's reasoning is set to `%s`. Available settings: %s.", name, displayReasoningLevel(current), reasoningOptionsText(model))
+}
+
+func reasoningStatusModelName(model ai.Model, canonicalModel string) string {
+ if model.Name != "" && model.Name != model.ID {
+ return model.Name
+ }
+ return canonicalModel
+}
+
+func displayReasoningMode(mode string) string {
+ if mode == "" {
+ return "default"
+ }
+ return mode
+}
+
+func reasoningModeStatusText(current string, canonicalModel string, model ai.Model) string {
+ return fmt.Sprintf("%s's reasoning mode is set to `%s`. Available modes: %s.", reasoningStatusModelName(model, canonicalModel), displayReasoningMode(current), reasoningModeOptionsText(model))
+}
+
+func reasoningModeSentence(mode string) string {
+ if mode == "" {
+ return ""
+ }
+ return fmt.Sprintf(" Reasoning mode: `%s`.", mode)
}
func reasoningOptionsText(model ai.Model) string {
@@ -102,8 +192,20 @@ func reasoningOptionsText(model ai.Model) string {
return strings.Join(options, ", ")
}
-func (cl *Client) modelStatusText(currentModel string, currentReasoning string, currentProvider aiid.ProviderConfig) string {
- return fmt.Sprintf("Current model is `%s`. Current reasoning is `%s`. Options: %s.", currentModel, currentReasoning, cl.modelOptionsText(currentProvider))
+func reasoningModeOptionsText(model ai.Model) string {
+ options := []string{"`default`"}
+ if strings.EqualFold(string(model.ReasoningMode), string(ai.ModelReasoningModeAdaptive)) {
+ options = append(options, "`adaptive`")
+ }
+ return strings.Join(options, ", ")
+}
+
+func (cl *Client) modelStatusText(currentModel string, currentReasoning string, currentReasoningMode string, currentProvider aiid.ProviderConfig) string {
+ text := fmt.Sprintf("Current model: `%s`. Reasoning: `%s`.", currentModel, currentReasoning)
+ if currentReasoningMode != "" {
+ text += fmt.Sprintf(" Reasoning mode: `%s`.", currentReasoningMode)
+ }
+ return fmt.Sprintf("%s Available models: %s.", text, cl.modelOptionsText(currentProvider))
}
func (cl *Client) modelOptionsText(currentProvider aiid.ProviderConfig) string {
@@ -123,10 +225,6 @@ func (cl *Client) modelOptionsText(currentProvider aiid.ProviderConfig) string {
options := []string{}
for _, providerID := range providerIDs {
provider := providers[providerID]
- if len(provider.Models) == 0 && providerAllowsArbitraryModels(provider) {
- options = append(options, fmt.Sprintf("`%s/`", provider.ID))
- continue
- }
for _, model := range contactModels(provider) {
options = append(options, fmt.Sprintf("`%s/%s`", provider.ID, model.ID))
}
@@ -145,3 +243,12 @@ func validRoomReasoningLevel(level string) bool {
return false
}
}
+
+func validRoomReasoningMode(mode string) bool {
+ switch mode {
+ case "", "default", string(ai.ModelReasoningModeAdaptive):
+ return true
+ default:
+ return false
+ }
+}
diff --git a/pkg/connector/slash_commands_session.go b/pkg/connector/slash_commands_session.go
index dacd69f8..48d922a0 100644
--- a/pkg/connector/slash_commands_session.go
+++ b/pkg/connector/slash_commands_session.go
@@ -40,6 +40,7 @@ type sessionCommandInfo struct {
Responding bool
ActiveRunID string
ActiveModel string
+ LastKnownTimezone string
Stats sessionCommandStats
ContextMessages int
EstimatedTokens int
@@ -60,6 +61,7 @@ func runSessionCommand(cl *Client, ctx context.Context, portal *bridgev2.Portal,
SystemPrompt: strings.TrimSpace(roomConfig.AdditionalPrompt) != "",
SystemPromptChars: len([]rune(strings.TrimSpace(roomConfig.AdditionalPrompt))),
Responding: active != nil,
+ LastKnownTimezone: cl.lastKnownTimezone(),
}
if active != nil {
info.ActiveRunID = active.runID
@@ -184,6 +186,9 @@ func formatSessionCommandInfo(info sessionCommandInfo) string {
if info.ActiveModel != "" {
fmt.Fprintf(&text, "\n- Active model: `%s`", info.ActiveModel)
}
+ if info.LastKnownTimezone != "" {
+ fmt.Fprintf(&text, "\n- Last known timezone: `%s`", info.LastKnownTimezone)
+ }
systemPrompt := "no"
if info.SystemPrompt {
systemPrompt = fmt.Sprintf("yes, %d chars", info.SystemPromptChars)
diff --git a/pkg/connector/slash_commands_state.go b/pkg/connector/slash_commands_state.go
index dfae0ee3..270b2159 100644
--- a/pkg/connector/slash_commands_state.go
+++ b/pkg/connector/slash_commands_state.go
@@ -48,11 +48,22 @@ func (cl *Client) normalizeRoomStateForPrompt(ctx context.Context, msg *bridgev2
}
return config, cl.commandHandledResponse(msg, "invalid-settings"), true, nil
}
+ if err = cl.validateReasoningMode(model, config); err != nil {
+ if !config.modelStatePresent {
+ return config, nil, false, err
+ }
+ cl.logAIRoomSettingsError(ctx, msg, err, "AI room settings rejected")
+ if noticeErr := cl.sendCommandNotice(ctx, msg.Portal, fmt.Sprintf("AI room settings rejected: %v.", err)); noticeErr != nil {
+ return config, nil, false, noticeErr
+ }
+ return config, cl.commandHandledResponse(msg, "invalid-settings"), true, nil
+ }
config.ThinkingLevel = cl.reasoningLevelForModel(model, config)
- normalized := config.modelStatePresent && (config.modelStateModel != canonical || config.modelStateReason != config.ThinkingLevel)
+ config.ReasoningMode = cl.reasoningModeForModel(model, config)
+ normalized := config.modelStatePresent && (config.modelStateModel != canonical || config.modelStateReason != config.ThinkingLevel || config.modelStateReasoningMode != config.ReasoningMode)
nameChanged := config.modelStatePresent && model.Name != "" && config.modelStateName != model.Name
if normalized || nameChanged {
- if _, err = cl.writeRoomModelState(ctx, msg.Portal, provider, model, canonical, config.ThinkingLevel); err != nil {
+ if _, err = cl.writeRoomModelState(ctx, msg.Portal, provider, model, canonical, config.ThinkingLevel, config.ReasoningMode); err != nil {
return config, nil, false, err
}
cl.refreshRoomCapabilities(ctx, msg.Portal)
@@ -111,12 +122,12 @@ type applyRoomModelStateOptions struct {
ForceAvatar bool
}
-func (cl *Client) writeRoomModelState(ctx context.Context, portal *bridgev2.Portal, provider aiid.ProviderConfig, model ai.Model, canonicalModel string, reasoning string) (string, error) {
- return cl.applyRoomModelState(ctx, portal, provider, model, canonicalModel, reasoning, applyRoomModelStateOptions{})
+func (cl *Client) writeRoomModelState(ctx context.Context, portal *bridgev2.Portal, provider aiid.ProviderConfig, model ai.Model, canonicalModel string, reasoning string, reasoningMode string) (string, error) {
+ return cl.applyRoomModelState(ctx, portal, provider, model, canonicalModel, reasoning, reasoningMode, applyRoomModelStateOptions{})
}
-func (cl *Client) applyRoomModelState(ctx context.Context, portal *bridgev2.Portal, provider aiid.ProviderConfig, model ai.Model, canonicalModel string, reasoning string, opts applyRoomModelStateOptions) (string, error) {
- content := roomModelStateContent(model, canonicalModel, reasoning)
+func (cl *Client) applyRoomModelState(ctx context.Context, portal *bridgev2.Portal, provider aiid.ProviderConfig, model ai.Model, canonicalModel string, reasoning string, reasoningMode string, opts applyRoomModelStateOptions) (string, error) {
+ content := roomModelStateContent(model, canonicalModel, reasoning, reasoningMode)
eventID, err := cl.writeAIRoomState(ctx, portal, aiid.RoomModelType, content)
if err != nil {
return eventID, err
@@ -128,7 +139,7 @@ func (cl *Client) applyRoomModelState(ctx context.Context, portal *bridgev2.Port
return eventID, nil
}
-func roomModelStateContent(model ai.Model, canonicalModel string, reasoning string) map[string]any {
+func roomModelStateContent(model ai.Model, canonicalModel string, reasoning string, reasoningMode string) map[string]any {
content := map[string]any{"model": canonicalModel}
if model.Name != "" {
content["name"] = model.Name
@@ -136,6 +147,9 @@ func roomModelStateContent(model ai.Model, canonicalModel string, reasoning stri
if reasoning != "" {
content["reasoning"] = reasoning
}
+ if reasoningMode != "" {
+ content["reasoning_mode"] = reasoningMode
+ }
return content
}
diff --git a/pkg/connector/slash_commands_test.go b/pkg/connector/slash_commands_test.go
index ee0f3818..cfea31f4 100644
--- a/pkg/connector/slash_commands_test.go
+++ b/pkg/connector/slash_commands_test.go
@@ -32,6 +32,8 @@ func TestParseAISlashCommand(t *testing.T) {
{body: "/model", name: "model", ok: true},
{body: " /reasoning high ", name: "reasoning", arg: "high", ok: true},
{body: "/reasoning", name: "reasoning", ok: true},
+ {body: "/reasoning-mode adaptive", name: "reasoning-mode", arg: "adaptive", ok: true},
+ {body: "/reasoning-mode", name: "reasoning-mode", ok: true},
{body: "/reasoniing low", ok: false},
{body: "/system-prompt be terse", name: "system-prompt", arg: "be terse", ok: true},
{body: "/system-prompt", name: "system-prompt", ok: true},
@@ -187,20 +189,53 @@ func TestCurrentCommandResponseText(t *testing.T) {
}
model := ai.Model{ID: "anthropic/claude-opus-4.5", Reasoning: true}
status := reasoningStatusText("", "beeper/anthropic/claude-opus-4.5", model)
- if !strings.Contains(status, "Current reasoning is `off` for `beeper/anthropic/claude-opus-4.5`.") {
+ if !strings.Contains(status, "beeper/anthropic/claude-opus-4.5's reasoning is set to `off`.") {
t.Fatalf("reasoning status is missing current value:\n%s", status)
}
- if !strings.Contains(status, "Options: `off`, `minimal`, `low`, `medium`, `high`.") {
+ if !strings.Contains(status, "Available settings: `off`, `minimal`, `low`, `medium`, `high`.") {
t.Fatalf("reasoning status is missing supported options:\n%s", status)
}
- modelStatus := canonicalTestClient().modelStatusText("beeper/gpt-5.5", "off", aiid.ProviderConfig{
+ geminiStatus := reasoningStatusText("off", "beeper/google/gemini-3-pro-image-preview", ai.Model{
+ ID: "google/gemini-3-pro-image-preview",
+ Name: "Gemini 3 Pro Image Preview",
+ Reasoning: true,
+ })
+ if geminiStatus != "Gemini 3 Pro Image Preview's reasoning is set to `off`. Available settings: `off`, `minimal`, `low`, `medium`, `high`." {
+ t.Fatalf("unexpected Gemini reasoning status:\n%s", geminiStatus)
+ }
+ unsupportedStatus := reasoningStatusText("off", "beeper/meta-llama/llama-3.3-70b-instruct", ai.Model{ID: "meta-llama/llama-3.3-70b-instruct", Name: "Llama 3.3 70B"})
+ if unsupportedStatus != "Llama 3.3 70B doesn't support reasoning." {
+ t.Fatalf("unexpected unsupported reasoning status:\n%s", unsupportedStatus)
+ }
+ fixedStatus := reasoningStatusText("low", "beeper/minimax/minimax-m2.7", ai.Model{
+ ID: "minimax/minimax-m2.7",
+ Name: "MiniMax M2.7",
+ Reasoning: true,
+ ThinkingLevelMap: map[ai.ModelThinkingLevel]*string{
+ ai.ModelThinkingLevelOff: nil,
+ ai.ModelThinkingLevelMinimal: nil,
+ ai.ModelThinkingLevelMedium: nil,
+ ai.ModelThinkingLevelHigh: nil,
+ },
+ })
+ if fixedStatus != "MiniMax M2.7's reasoning is set to `low` and it doesn't support changing reasoning settings." {
+ t.Fatalf("unexpected fixed reasoning status:\n%s", fixedStatus)
+ }
+ modeStatus := reasoningModeStatusText("adaptive", "beeper/anthropic/claude-opus-4.8", ai.Model{ID: "anthropic/claude-opus-4.8", ReasoningMode: ai.ModelReasoningModeAdaptive})
+ if !strings.Contains(modeStatus, "beeper/anthropic/claude-opus-4.8's reasoning mode is set to `adaptive`.") {
+ t.Fatalf("reasoning mode status is missing current value:\n%s", modeStatus)
+ }
+ if !strings.Contains(modeStatus, "Available modes: `default`, `adaptive`.") {
+ t.Fatalf("reasoning mode status is missing supported options:\n%s", modeStatus)
+ }
+ modelStatus := canonicalTestClient().modelStatusText("beeper/gpt-5.5", "off", "", aiid.ProviderConfig{
ID: "beeper",
Models: []ai.Model{{ID: "gpt-5.5"}, {ID: "openai/gpt-5.5"}},
})
- if !strings.Contains(modelStatus, "Current model is `beeper/gpt-5.5`. Current reasoning is `off`.") {
+ if !strings.Contains(modelStatus, "Current model: `beeper/gpt-5.5`. Reasoning: `off`.") {
t.Fatalf("model status is missing current value:\n%s", modelStatus)
}
- if !strings.Contains(modelStatus, "Options: `beeper/gpt-5.5`, `beeper/openai/gpt-5.5`.") {
+ if !strings.Contains(modelStatus, "Available models: `beeper/gpt-5.5`, `beeper/openai/gpt-5.5`.") {
t.Fatalf("model status is missing available options:\n%s", modelStatus)
}
if got := currentSystemPromptText(RoomConfig{}); got != "No additional system prompt is set." {
@@ -357,13 +392,14 @@ func TestSessionCommandStatsFromEntries(t *testing.T) {
func TestFormatSessionCommandInfo(t *testing.T) {
text := formatSessionCommandInfo(sessionCommandInfo{
- SessionID: "session-1",
- CreatedAt: "2026-05-30T00:00:00Z",
- RoomProvider: "beeper",
- RoomModel: "beeper/gpt-5.5",
- RoomReasoning: "off",
- SystemPrompt: true,
- Responding: true,
+ SessionID: "session-1",
+ CreatedAt: "2026-05-30T00:00:00Z",
+ RoomProvider: "beeper",
+ RoomModel: "beeper/gpt-5.5",
+ RoomReasoning: "off",
+ SystemPrompt: true,
+ Responding: true,
+ LastKnownTimezone: "Europe/Amsterdam",
Stats: sessionCommandStats{
TotalEntries: 4,
Messages: 3,
@@ -377,6 +413,7 @@ func TestFormatSessionCommandInfo(t *testing.T) {
"Status: `responding`",
"ID: `session-1`",
"Room model: `beeper/gpt-5.5`",
+ "Last known timezone: `Europe/Amsterdam`",
"System prompt: `yes, 0 chars`",
"Messages: `3` total, `1` user, `1` assistant, `1` tool results",
"Compactions: `1`",
@@ -392,16 +429,12 @@ func TestFormatSessionCommandInfo(t *testing.T) {
}
}
-func TestAIServicesLimitsURLStripsProviderProxyPaths(t *testing.T) {
+func TestAIServicesLimitsURLUsesBaseURL(t *testing.T) {
tests := map[string]string{
- "https://ai-services.beeper.com/proxy/openai/v1": "https://ai-services.beeper.com/limits",
- "https://ai-services.beeper.com/proxy/openrouter/v1": "https://ai-services.beeper.com/limits",
- "https://ai-services.beeper.com/proxy/anthropic": "https://ai-services.beeper.com/limits",
- "https://ai-services.beeper.com/proxy/vertex": "https://ai-services.beeper.com/limits",
- "https://ai-services.beeper.com/proxy/a8c/v1": "https://ai-services.beeper.com/limits",
- "https://ai-services.beeper.com/proxy/_/v1/responses": "https://ai-services.beeper.com/limits",
- "https://ai-services.beeper.com/dev/proxy/openai/v1": "https://ai-services.beeper.com/dev/limits",
- "https://ai-services.beeper.com/dev/proxy/openrouter/v1/": "https://ai-services.beeper.com/dev/limits",
+ "https://ai-services.beeper.com": "https://ai-services.beeper.com/limits",
+ "https://ai-services.beeper.com/": "https://ai-services.beeper.com/limits",
+ "https://ai-services.beeper.com/dev": "https://ai-services.beeper.com/dev/limits",
+ "https://ai-services.beeper.com/dev/": "https://ai-services.beeper.com/dev/limits",
}
for input, want := range tests {
got, err := aiServicesLimitsURL(input)
@@ -439,7 +472,7 @@ func TestFetchAIServicesLimitsUsesAppserviceBearerToken(t *testing.T) {
}))
defer server.Close()
- provider := aiid.ProviderConfig{ID: aiid.DefaultProvider, BaseURL: server.URL + "/proxy/openai/v1"}
+ provider := aiid.ProviderConfig{ID: aiid.DefaultProvider, BaseURL: server.URL}
client := &Client{
Main: &Connector{AppServiceToken: "as-token"},
UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{
@@ -458,6 +491,52 @@ func TestFetchAIServicesLimitsUsesAppserviceBearerToken(t *testing.T) {
}
}
+func TestRunLimitsCommandRawUsesAIResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/limits" {
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ _, _ = w.Write([]byte(`{"windows":{"llm":{"day":{"percentage_left":75,"limit":1000,"used":250,"remaining":750,"reset_at":1893456000000}}}}`))
+ }))
+ defer server.Close()
+
+ provider := aiid.ProviderConfig{ID: aiid.DefaultProvider, BaseURL: server.URL}
+ client := &Client{
+ Main: &Connector{AppServiceToken: "as-token"},
+ UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{
+ UserMXID: "@alice:beeper.test",
+ Metadata: &aiid.UserLoginMetadata{Providers: map[string]aiid.ProviderConfig{
+ provider.ID: provider,
+ }},
+ }},
+ }
+ responder := &recordingCommandResponder{}
+ if err := runLimitsCommand(client, context.Background(), nil, RoomConfig{}, "raw", responder); err != nil {
+ t.Fatal(err)
+ }
+ if responder.text != "" {
+ t.Fatalf("raw limits should use AI response, got plain text %q", responder.text)
+ }
+ if !strings.Contains(responder.aiText, "## LLM tokens") || !strings.Contains(responder.aiText, "| Day | `75%` | `250` | `1,000` | `750` |") {
+ t.Fatalf("raw limits AI response missing table:\n%s", responder.aiText)
+ }
+}
+
+type recordingCommandResponder struct {
+ text string
+ aiText string
+}
+
+func (r *recordingCommandResponder) Reply(_ context.Context, text string) error {
+ r.text = text
+ return nil
+}
+
+func (r *recordingCommandResponder) ReplyAI(_ context.Context, text string) error {
+ r.aiText = text
+ return nil
+}
+
func TestBeeperUsageLimitErrorUsesPlanResetMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/limits" {
@@ -467,7 +546,7 @@ func TestBeeperUsageLimitErrorUsesPlanResetMessage(t *testing.T) {
}))
defer server.Close()
- provider := aiid.ProviderConfig{ID: aiid.DefaultProvider, BaseURL: server.URL + "/proxy/openai/v1"}
+ provider := aiid.ProviderConfig{ID: aiid.DefaultProvider, BaseURL: server.URL}
client := &Client{
Main: &Connector{AppServiceToken: "as-token"},
UserLogin: &bridgev2.UserLogin{UserLogin: &database.UserLogin{
@@ -532,24 +611,22 @@ func TestFormatLimitsCommandInfo(t *testing.T) {
},
}}, now)
for _, want := range []string{
- "AI limits",
+ "# AI limits",
"## Models",
+ "## Web Search",
+ "## Transcription",
+ "## Audio Generation",
"| Window | Left | Used | Reset |",
"| Daily | `75%` | `250 / 1,000` | in 1 day 2 hours 3 minutes |",
"| Weekly | Unlimited | `1,234` used | in 1 day 2 hours 3 minutes |",
"| Monthly | **Out** | `30,500 / 30,000` | in 1 day 2 hours 3 minutes |",
- "## Web Search",
- "| Daily | `99%` | `1 / 200,000` | in 1 day 2 hours 3 minutes |",
- "## Transcription",
- "| Daily | `99%` | `1 minute / 1,440 minutes` | in 1 day 2 hours 3 minutes |",
- "## Audio Generation",
- "| Daily | `99%` | `1,234 / 50,000 chars` | in 1 day 2 hours 3 minutes |",
+ "`1 / 200,000`",
} {
if !strings.Contains(text, want) {
t.Fatalf("limits info missing %q:\n%s", want, text)
}
}
- for _, notWant := range []string{"`199,999`", "2030-01-01T00:00:00Z"} {
+ for _, notWant := range []string{"2030-01-01T00:00:00Z"} {
if strings.Contains(text, notWant) {
t.Fatalf("limits info exposed non-summary value %q:\n%s", notWant, text)
}
@@ -566,19 +643,20 @@ func TestFormatLimitsCommandInfoShowsPerWindowResetsWhenDifferent(t *testing.T)
},
}}, now)
for _, want := range []string{
+ "# AI limits",
"## Models",
"| Daily | `75%` | Not reported | in 1 day 1 hour 3 minutes |",
"| Weekly | `100%` | Not reported | in 7 days |",
"| Monthly | **Out** | Not reported | in 31 days |",
- "## Web Search",
- "No limits reported.",
} {
if !strings.Contains(text, want) {
t.Fatalf("limits info missing %q:\n%s", want, text)
}
}
- if strings.Contains(text, "Everything resets") {
- t.Fatalf("limits info collapsed different reset times:\n%s", text)
+ for _, notWant := range []string{"## Web Search", "No limits reported.", "Everything resets"} {
+ if strings.Contains(text, notWant) {
+ t.Fatalf("limits info exposed %q:\n%s", notWant, text)
+ }
}
}
@@ -612,16 +690,23 @@ func TestFormatRawLimitsCommandInfoShowsExactUsage(t *testing.T) {
},
}}, time.Date(2029, 12, 31, 0, 0, 0, 0, time.UTC))
for _, want := range []string{
- "AI limits raw:",
- "percentage_left=`75`, limit=`1,000`, used=`250`, remaining=`750`, reset_at=`1893456000000` (`2030-01-01T00:00:00Z`, in 1 day)",
- "percentage_left=`100`, limit=`-1`, used=`1,234`, remaining=`-1`",
- "Audio transcription seconds:",
- "percentage_left=`99`, limit=`86,400`, used=`43`, remaining=`86,357`",
+ "## LLM tokens",
+ "| Window | Left | Used | Limit | Remaining | Reset |",
+ "| Day | `75%` | `250` | `1,000` | `750` | `1893456000000` (`2030-01-01T00:00:00Z`, in 1 day) |",
+ "| Week | `100%` | `1,234` | `-1` | `-1` | `1893456000000` (`2030-01-01T00:00:00Z`, in 1 day) |",
+ "## Web tools",
+ "| Day | `0%` | `0` | `0` | `0` | unknown |",
+ "## Audio transcription seconds",
+ "| Day | `99%` | `43` | `86,400` | `86,357` | `1893456000000` (`2030-01-01T00:00:00Z`, in 1 day) |",
+ "## Audio generation characters",
} {
if !strings.Contains(text, want) {
t.Fatalf("raw limits info missing %q:\n%s", want, text)
}
}
+ if strings.Contains(text, "AI limits raw:") {
+ t.Fatalf("raw limits info should render as tables without the old header:\n%s", text)
+ }
}
func TestResolveCanonicalRoomModelUsesDefaultProviderForBareModel(t *testing.T) {
diff --git a/pkg/connector/slash_commands_tools.go b/pkg/connector/slash_commands_tools.go
new file mode 100644
index 00000000..d7edba7f
--- /dev/null
+++ b/pkg/connector/slash_commands_tools.go
@@ -0,0 +1,45 @@
+package connector
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "maunium.net/go/mautrix/bridgev2"
+
+ "github.com/beeper/ai-bridge/pkg/aiid"
+)
+
+func runSearchModeCommand(cl *Client, ctx context.Context, portal *bridgev2.Portal, roomConfig RoomConfig, arg string, responder aiCommandResponder) error {
+ arg = strings.ToLower(strings.TrimSpace(arg))
+ if arg == "" {
+ return responder.Reply(ctx, fmt.Sprintf("Current search mode is `%s`. Options: `off`, `beeper`, `native`.", roomSearchMode(roomConfig)))
+ }
+ mode := normalizedToolMode(arg, "")
+ if mode != toolModeOff && mode != toolModeBeeper && mode != toolModeNative {
+ return fmt.Errorf("search mode %q is invalid", arg)
+ }
+ roomConfig.SearchMode = mode
+ if _, err := cl.writeAIRoomState(ctx, portal, aiid.RoomToolsType, toolModeStateContent(roomConfig)); err != nil {
+ return err
+ }
+ cl.refreshRoomCapabilities(ctx, portal)
+ return responder.Reply(ctx, fmt.Sprintf("Search mode set to `%s`.", mode))
+}
+
+func runFetchModeCommand(cl *Client, ctx context.Context, portal *bridgev2.Portal, roomConfig RoomConfig, arg string, responder aiCommandResponder) error {
+ arg = strings.ToLower(strings.TrimSpace(arg))
+ if arg == "" {
+ return responder.Reply(ctx, fmt.Sprintf("Current fetch mode is `%s`. Options: `off`, `beeper`, `native`.", roomFetchMode(roomConfig)))
+ }
+ mode := normalizedToolMode(arg, "")
+ if mode != toolModeOff && mode != toolModeBeeper && mode != toolModeNative {
+ return fmt.Errorf("fetch mode %q is invalid", arg)
+ }
+ roomConfig.FetchMode = mode
+ if _, err := cl.writeAIRoomState(ctx, portal, aiid.RoomToolsType, toolModeStateContent(roomConfig)); err != nil {
+ return err
+ }
+ cl.refreshRoomCapabilities(ctx, portal)
+ return responder.Reply(ctx, fmt.Sprintf("Fetch mode set to `%s`.", mode))
+}
diff --git a/pkg/connector/sources.go b/pkg/connector/sources.go
index 799fcd26..f9ce6ea3 100644
--- a/pkg/connector/sources.go
+++ b/pkg/connector/sources.go
@@ -3,7 +3,10 @@ package connector
import (
"fmt"
"net/url"
+ "regexp"
"strings"
+
+ ai "github.com/beeper/ai-bridge/pkg/ai"
)
type sourceCollector struct {
@@ -38,12 +41,16 @@ type canonicalSource struct {
}
type sourceAppearance struct {
- Kind string
- ToolCallID string
- ToolName string
- Query string
- Rank int
- Cited bool
+ Kind string
+ ToolCallID string
+ ToolName string
+ Query string
+ Rank int
+ Cited bool
+ StartIndex *int
+ EndIndex *int
+ ContentIndex *int
+ Text string
}
func newSourceCollector() *sourceCollector {
@@ -69,7 +76,7 @@ func (c *sourceCollector) addWebSearchOutput(output toolOutputEvent, result any)
if data == nil {
return nil
}
- rawResults, _ := data["results"].([]any)
+ rawResults := sourceSlice(data, "results")
if len(rawResults) == 0 {
return nil
}
@@ -104,11 +111,11 @@ func (c *sourceCollector) addSearchResultSource(output toolOutputEvent, query st
source := sourceObservation{
URL: sourceString(item, "url", "URL", "uri"),
Title: sourceString(item, "title"),
- Description: firstSourceString(sourceDescriptionString(item), firstStringFromSlice(item["highlights"]), sourceString(item, "text")),
+ Description: firstSourceString(sourceDescriptionString(item), firstStringFromSlice(item["highlights"])),
SiteName: sourceString(item, "siteName", "site_name", "source"),
FaviconURL: sourceFaviconString(item),
ImageURL: sourceImageString(item),
- PublishedAt: sourceString(item, "published", "publishedAt", "publishedDate", "datePublished", "date"),
+ PublishedAt: sourceString(item, "published", "publishedAt", "published_at", "publishedDate", "datePublished", "date"),
Priority: priority,
Appearance: sourceAppearance{
Kind: "web_search",
@@ -125,7 +132,7 @@ func (c *sourceCollector) addSearchResultSource(output toolOutputEvent, query st
source.SiteName = firstSourceString(source.SiteName, sourceString(nested, "siteName", "site_name", "ogSiteName"))
source.FaviconURL = firstSourceString(source.FaviconURL, sourceFaviconString(nested))
source.ImageURL = firstSourceString(source.ImageURL, sourceImageString(nested))
- source.PublishedAt = firstSourceString(source.PublishedAt, sourceString(nested, "published", "publishedAt", "publishedDate", "datePublished", "date"))
+ source.PublishedAt = firstSourceString(source.PublishedAt, sourceString(nested, "published", "publishedAt", "published_at", "publishedDate", "datePublished", "date"))
}
return c.add(source)
}
@@ -163,16 +170,16 @@ func (c *sourceCollector) addFetchOutput(output toolOutputEvent, result any) []m
source := sourceObservation{
URL: firstSourceString(sourceString(data, "final_url", "finalUrl"), sourceString(data, "url")),
Title: sourceString(data, "title"),
- Description: firstSourceString(sourceDescriptionString(data), firstStringFromSlice(data["highlights"]), sourceString(data, "text")),
+ Description: firstSourceString(sourceDescriptionString(data), firstStringFromSlice(data["highlights"])),
SiteName: sourceString(data, "siteName", "site_name", "source"),
FaviconURL: sourceFaviconString(data),
ImageURL: sourceString(data, "image", "imageUrl", "image_url"),
- PublishedAt: sourceString(data, "published", "publishedAt", "publishedDate", "datePublished", "date"),
+ PublishedAt: sourceString(data, "published", "publishedAt", "published_at", "publishedDate", "datePublished", "date"),
Priority: 100,
Appearance: sourceAppearance{
Kind: "fetch",
ToolCallID: output.ID,
- ToolName: output.Name,
+ ToolName: "fetch",
},
}
if updated := c.add(source); updated != nil {
@@ -183,6 +190,20 @@ func (c *sourceCollector) addFetchOutput(output toolOutputEvent, result any) []m
func (c *sourceCollector) addProviderSources(message any) []map[string]any {
changed := []map[string]any{}
+ if typed, ok := message.(ai.Message); ok {
+ for _, citation := range typed.Citations {
+ if updated := c.add(providerCitationObservation(citation)); updated != nil {
+ changed = append(changed, updated)
+ }
+ }
+ }
+ if typed, ok := message.(*ai.Message); ok && typed != nil {
+ for _, citation := range typed.Citations {
+ if updated := c.add(providerCitationObservation(citation)); updated != nil {
+ changed = append(changed, updated)
+ }
+ }
+ }
walkProviderSources(message, func(source sourceObservation) {
source.Priority = 80
source.Appearance.Kind = "provider"
@@ -194,6 +215,45 @@ func (c *sourceCollector) addProviderSources(message any) []map[string]any {
return changed
}
+func (c *sourceCollector) addAnswerURLSources(message ai.Message) []map[string]any {
+ changed := []map[string]any{}
+ for _, rawURL := range extractMessageURLs(message) {
+ obs := sourceObservation{
+ URL: rawURL,
+ Priority: 70,
+ Appearance: sourceAppearance{
+ Kind: "answer",
+ Cited: true,
+ },
+ }
+ if updated := c.add(obs); updated != nil {
+ changed = append(changed, updated)
+ }
+ }
+ return changed
+}
+
+func providerCitationObservation(citation ai.Citation) sourceObservation {
+ return sourceObservation{
+ URL: citation.URL,
+ Title: citation.Title,
+ Description: citation.Description,
+ SiteName: citation.SiteName,
+ FaviconURL: citation.FaviconURL,
+ ImageURL: citation.ImageURL,
+ PublishedAt: citation.PublishedAt,
+ Priority: 80,
+ Appearance: sourceAppearance{
+ Kind: "provider",
+ Cited: true,
+ StartIndex: citation.StartIndex,
+ EndIndex: citation.EndIndex,
+ ContentIndex: citation.ContentIndex,
+ Text: citation.Text,
+ },
+ }
+}
+
func (c *sourceCollector) add(obs sourceObservation) map[string]any {
normalized, ok := normalizeSourceURL(obs.URL)
if !ok {
@@ -316,7 +376,7 @@ func (s *canonicalSource) addAppearance(appearance sourceAppearance) bool {
if appearance.Kind == "" {
return false
}
- key := fmt.Sprintf("%s|%s|%s|%s|%d|%t", appearance.Kind, appearance.ToolCallID, appearance.ToolName, appearance.Query, appearance.Rank, appearance.Cited)
+ key := fmt.Sprintf("%s|%s|%s|%s|%d|%t|%s|%s|%s|%s", appearance.Kind, appearance.ToolCallID, appearance.ToolName, appearance.Query, appearance.Rank, appearance.Cited, intPtrKey(appearance.StartIndex), intPtrKey(appearance.EndIndex), intPtrKey(appearance.ContentIndex), appearance.Text)
if _, exists := s.seen[key]; exists {
return false
}
@@ -361,6 +421,75 @@ func (c *sourceCollector) sources() []map[string]any {
return out
}
+var markdownURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
+
+func extractMessageURLs(message ai.Message) []string {
+ text := strings.TrimSpace(messageTextContent(message.Content))
+ if text == "" {
+ return nil
+ }
+ seen := map[string]bool{}
+ var out []string
+ for _, match := range markdownURLPattern.FindAllString(text, -1) {
+ match = trimExtractedURL(match)
+ normalized, ok := normalizeSourceURL(match)
+ if !ok || seen[normalized] {
+ continue
+ }
+ seen[normalized] = true
+ out = append(out, normalized)
+ }
+ return out
+}
+
+func trimExtractedURL(raw string) string {
+ raw = strings.TrimRight(strings.TrimSpace(raw), ".,;:!?")
+ for len(raw) > 0 {
+ last := raw[len(raw)-1]
+ switch last {
+ case ')':
+ if strings.Count(raw, ")") <= strings.Count(raw, "(") {
+ return raw
+ }
+ case ']':
+ if strings.Count(raw, "]") <= strings.Count(raw, "[") {
+ return raw
+ }
+ default:
+ return raw
+ }
+ raw = strings.TrimRight(raw[:len(raw)-1], ".,;:!?")
+ }
+ return raw
+}
+
+func messageTextContent(content any) string {
+ switch typed := content.(type) {
+ case string:
+ return typed
+ case []ai.ContentBlock:
+ var parts []string
+ for _, block := range typed {
+ if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
+ parts = append(parts, block.Text)
+ }
+ }
+ return strings.Join(parts, "\n")
+ case []any:
+ var parts []string
+ for _, item := range typed {
+ if block, ok := item.(map[string]any); ok && stringFromAny(block["type"]) == "text" {
+ if text := stringFromAny(block["text"]); strings.TrimSpace(text) != "" {
+ parts = append(parts, text)
+ }
+ }
+ }
+ return strings.Join(parts, "\n")
+ default:
+ return ""
+ }
+}
+
func sourceAppearances(values []sourceAppearance) []map[string]any {
out := make([]map[string]any, 0, len(values))
for _, value := range values {
@@ -380,11 +509,30 @@ func sourceAppearances(values []sourceAppearance) []map[string]any {
if value.Cited {
item["cited"] = true
}
+ if value.StartIndex != nil {
+ item["startIndex"] = *value.StartIndex
+ }
+ if value.EndIndex != nil {
+ item["endIndex"] = *value.EndIndex
+ }
+ if value.ContentIndex != nil {
+ item["contentIndex"] = *value.ContentIndex
+ }
+ if value.Text != "" {
+ item["text"] = value.Text
+ }
out = append(out, item)
}
return out
}
+func intPtrKey(value *int) string {
+ if value == nil {
+ return ""
+ }
+ return fmt.Sprintf("%d", *value)
+}
+
func normalizeSourceURL(raw string) (string, bool) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed == nil || parsed.Scheme == "" || parsed.Host == "" {
@@ -415,18 +563,8 @@ func walkProviderSources(value any, emit func(sourceObservation)) {
case nil:
return
case map[string]any:
- sourceType := strings.ToLower(sourceString(typed, "type"))
- rawURL := sourceString(typed, "url", "uri")
- if rawURL != "" && strings.Contains(sourceType, "citation") {
- emit(sourceObservation{
- URL: rawURL,
- Title: sourceString(typed, "title"),
- Description: firstSourceString(sourceDescriptionString(typed), sourceString(typed, "text")),
- SiteName: sourceString(typed, "siteName", "site_name"),
- FaviconURL: sourceFaviconString(typed),
- ImageURL: sourceImageString(typed),
- PublishedAt: sourceString(typed, "published", "publishedAt", "publishedDate", "datePublished", "date"),
- })
+ if source, ok := providerCitationSource(typed); ok {
+ emit(source)
}
for key, item := range typed {
lower := strings.ToLower(key)
@@ -451,6 +589,106 @@ func walkProviderSources(value any, emit func(sourceObservation)) {
}
}
+func providerCitationSource(data map[string]any) (sourceObservation, bool) {
+ sourceType := strings.ToLower(firstSourceString(sourceString(data, "rawType"), sourceString(data, "type")))
+ nested, _ := data["url_citation"].(map[string]any)
+ if nested == nil {
+ nested, _ = data["urlCitation"].(map[string]any)
+ }
+ citation := data
+ if nested != nil {
+ citation = mergeSourceMaps(data, nested)
+ sourceType = firstSourceString(
+ strings.ToLower(firstSourceString(sourceString(nested, "rawType"), sourceString(nested, "type"))),
+ sourceType,
+ )
+ }
+ rawURL := sourceString(citation, "url", "uri")
+ if rawURL == "" || (!strings.Contains(sourceType, "citation") && sourceType != "web_search_result_location" && sourceType != "web_search_result" && sourceType != "web_fetch_result" && sourceType != "openrouter:web_fetch" && sourceType != "url_context") {
+ return sourceObservation{}, false
+ }
+ title := sourceString(citation, "title")
+ if title == "" && (sourceType == "web_fetch_result" || sourceType == "openrouter:web_fetch") {
+ if content, _ := citation["content"].(map[string]any); content != nil {
+ title = sourceString(content, "title")
+ if title == "" {
+ title = sourceDocumentTitleFromProviderContent(content)
+ }
+ }
+ }
+ return sourceObservation{
+ URL: rawURL,
+ Title: title,
+ Description: firstSourceString(sourceDescriptionString(citation), sourceString(citation, "cited_text"), sourceString(citation, "text")),
+ SiteName: sourceString(citation, "siteName", "site_name"),
+ FaviconURL: sourceFaviconString(citation),
+ ImageURL: sourceImageString(citation),
+ PublishedAt: sourceString(citation, "published", "publishedAt", "published_at", "publishedDate", "datePublished", "date"),
+ Appearance: sourceAppearance{
+ Kind: "provider",
+ Cited: true,
+ StartIndex: intPointerFromAny(firstSourceAny(citation, "startIndex", "start_index")),
+ EndIndex: intPointerFromAny(firstSourceAny(citation, "endIndex", "end_index")),
+ ContentIndex: intPointerFromAny(firstSourceAny(citation, "contentIndex", "content_index", "outputIndex", "output_index")),
+ Text: firstSourceString(sourceString(citation, "text"), sourceString(citation, "cited_text")),
+ },
+ }, true
+}
+
+func sourceDocumentTitleFromProviderContent(content map[string]any) string {
+ source, _ := content["source"].(map[string]any)
+ text := firstSourceString(sourceString(content, "text", "data"), sourceString(source, "data"))
+ for _, line := range strings.Split(text, "\n") {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ return line
+ }
+ }
+ return ""
+}
+
+func mergeSourceMaps(first map[string]any, second map[string]any) map[string]any {
+ out := map[string]any{}
+ for key, value := range first {
+ out[key] = value
+ }
+ for key, value := range second {
+ out[key] = value
+ }
+ return out
+}
+
+func firstSourceAny(data map[string]any, keys ...string) any {
+ for _, key := range keys {
+ if value, ok := data[key]; ok {
+ return value
+ }
+ }
+ return nil
+}
+
+func intPointerFromAny(value any) *int {
+ var out int
+ switch typed := value.(type) {
+ case int:
+ out = typed
+ case int64:
+ out = int(typed)
+ case float64:
+ out = int(typed)
+ case string:
+ if typed = strings.TrimSpace(typed); typed == "" {
+ return nil
+ }
+ if _, err := fmt.Sscanf(typed, "%d", &out); err != nil {
+ return nil
+ }
+ default:
+ return nil
+ }
+ return &out
+}
+
func sourceString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(stringFromAny(data[key])); value != "" {
@@ -476,6 +714,15 @@ func sourceSlice(data map[string]any, keys ...string) []any {
out = append(out, value)
}
return out
+ case []map[string]any:
+ if len(typed) == 0 {
+ continue
+ }
+ out := make([]any, 0, len(typed))
+ for _, value := range typed {
+ out = append(out, value)
+ }
+ return out
}
}
return nil
diff --git a/pkg/connector/sources_test.go b/pkg/connector/sources_test.go
index 4dd3feee..5cee4859 100644
--- a/pkg/connector/sources_test.go
+++ b/pkg/connector/sources_test.go
@@ -1,6 +1,10 @@
package connector
-import "testing"
+import (
+ "testing"
+
+ ai "github.com/beeper/ai-bridge/pkg/ai"
+)
func TestSourceCollectorUsesDescriptionAndFaviconFallbacks(t *testing.T) {
webSources := newSourceCollector().addWebSearchOutput(toolOutputEvent{
@@ -36,17 +40,7 @@ func TestSourceCollectorUsesDescriptionAndFaviconFallbacks(t *testing.T) {
},
})
if len(webSources) != 3 {
- t.Fatalf("web source metadata fallback failed: %#v", webSources)
- }
- if webSources[0]["description"] != "Open Graph description" || webSources[0]["faviconUrl"] != "https://example.com/icon.png" || webSources[0]["imageUrl"] != "https://example.com/page-image.png" {
- t.Fatalf("web source metadata fallback failed: %#v", webSources[0])
- }
- if webSources[1]["url"] != "https://example.com/subpage" || webSources[1]["description"] != "Subpage summary" || webSources[1]["faviconUrl"] != "https://example.com/subpage.ico" {
- t.Fatalf("web subpage source metadata fallback failed: %#v", webSources[1])
- }
- appearances, ok := webSources[2]["appearances"].([]map[string]any)
- if webSources[2]["url"] != "https://example.com/grounded" || !ok || len(appearances) != 1 || !appearances[0]["cited"].(bool) {
- t.Fatalf("web grounding source metadata fallback failed: %#v", webSources[2])
+ t.Fatalf("web search helper should still normalize sources for grounded/provider-like payloads, got %#v", webSources)
}
fetchSources := newSourceCollector().addFetchOutput(toolOutputEvent{
@@ -62,10 +56,23 @@ func TestSourceCollectorUsesDescriptionAndFaviconFallbacks(t *testing.T) {
t.Fatalf("fetch source metadata fallback failed: %#v", fetchSources)
}
+ captchaSources := newSourceCollector().addFetchOutput(toolOutputEvent{
+ ID: "call-fetch-captcha",
+ Name: "fetch",
+ }, map[string]any{
+ "final_url": "https://example.com/world",
+ "text": `{"url":"https://geo.captcha-delivery.com/captcha/?cid=long"}`,
+ })
+ if len(captchaSources) != 1 || captchaSources[0]["description"] != "Source from example.com" {
+ t.Fatalf("fetch source should not use full page text as card description: %#v", captchaSources)
+ }
+
providerSources := newSourceCollector().addProviderSources(map[string]any{
"annotations": []any{map[string]any{
- "type": "url_citation",
- "url": "https://example.com/provider",
+ "type": "url_citation",
+ "url": "https://example.com/provider",
+ "start_index": float64(10),
+ "end_index": float64(20),
"metadata": map[string]any{
"twitterDescription": "Provider description",
"siteIcon": "https://example.com/provider.ico",
@@ -75,4 +82,124 @@ func TestSourceCollectorUsesDescriptionAndFaviconFallbacks(t *testing.T) {
if len(providerSources) != 1 || providerSources[0]["description"] != "Provider description" || providerSources[0]["faviconUrl"] != "https://example.com/provider.ico" {
t.Fatalf("provider source metadata fallback failed: %#v", providerSources)
}
+ appearances, ok := providerSources[0]["appearances"].([]map[string]any)
+ if !ok || len(appearances) != 1 || appearances[0]["startIndex"] != 10 || appearances[0]["endIndex"] != 20 {
+ t.Fatalf("provider citation ranges were not preserved: %#v", providerSources[0])
+ }
+
+ fetchResultSources := newSourceCollector().addProviderSources(map[string]any{
+ "type": "web_fetch_tool_result",
+ "content": map[string]any{
+ "type": "web_fetch_result",
+ "url": "https://example.com/provider-fetch",
+ "content": map[string]any{
+ "type": "document",
+ "title": "Provider Fetch",
+ },
+ },
+ })
+ if len(fetchResultSources) != 1 || fetchResultSources[0]["title"] != "Provider Fetch" {
+ t.Fatalf("provider web fetch result was not mapped: %#v", fetchResultSources)
+ }
+
+ openRouterFetchSources := newSourceCollector().addProviderSources(map[string]any{
+ "type": "openrouter:web_fetch",
+ "url": "https://example.com/openrouter-fetch",
+ "content": map[string]any{
+ "type": "document",
+ "source": map[string]any{
+ "type": "text",
+ "data": "\nOpenRouter Fetch Title\nBody text",
+ },
+ },
+ })
+ if len(openRouterFetchSources) != 1 || openRouterFetchSources[0]["title"] != "OpenRouter Fetch Title" {
+ t.Fatalf("OpenRouter web fetch source was not mapped: %#v", openRouterFetchSources)
+ }
+
+ nestedCitationSources := newSourceCollector().addProviderSources(map[string]any{
+ "type": "annotation",
+ "url_citation": map[string]any{
+ "type": "url_citation",
+ "url": "https://example.com/nested",
+ "title": "Nested Citation",
+ },
+ })
+ if len(nestedCitationSources) != 1 || nestedCitationSources[0]["title"] != "Nested Citation" {
+ t.Fatalf("nested provider citation was not mapped: %#v", nestedCitationSources)
+ }
+
+ nativeSearchSources := newSourceCollector().addWebSearchOutput(toolOutputEvent{
+ ID: "native-search",
+ Name: "web_search",
+ Input: map[string]any{"query": "q"},
+ }, map[string]any{
+ "native": true,
+ "results": []map[string]any{{
+ "type": "web_search_result",
+ "url": "https://example.com/native-search",
+ "title": "Native Search",
+ }},
+ })
+ if len(nativeSearchSources) != 1 || nativeSearchSources[0]["title"] != "Native Search" {
+ t.Fatalf("native search source was not mapped: %#v", nativeSearchSources)
+ }
+
+ messageSources := newSourceCollector().addProviderSources(ai.Message{
+ Citations: []ai.Citation{{
+ Type: "url_citation",
+ URL: "https://example.com/message-citation",
+ Title: "Typed Citation",
+ StartIndex: intPtr(30),
+ EndIndex: intPtr(40),
+ }},
+ })
+ if len(messageSources) != 1 || messageSources[0]["title"] != "Typed Citation" {
+ t.Fatalf("typed provider citation was not mapped: %#v", messageSources)
+ }
+
+ urlContextSources := newSourceCollector().addProviderSources(ai.Message{
+ Citations: []ai.Citation{{
+ Type: "url_citation",
+ URL: "https://example.com/url-context",
+ RawType: "url_context",
+ }},
+ })
+ if len(urlContextSources) != 1 || urlContextSources[0]["url"] != "https://example.com/url-context" {
+ t.Fatalf("Google URL context source was not mapped: %#v", urlContextSources)
+ }
+
+ collector := newSourceCollector()
+ searchSources := collector.addWebSearchOutput(toolOutputEvent{
+ ID: "call-search",
+ Name: "web_search",
+ Input: map[string]any{"query": "q"},
+ }, map[string]any{
+ "results": []any{map[string]any{
+ "title": "Known Result",
+ "url": "https://example.com/known?utm_source=noise",
+ }},
+ })
+ if len(searchSources) != 1 {
+ t.Fatalf("expected search source, got %#v", searchSources)
+ }
+ answerSources := collector.addAnswerURLSources(ai.Message{Content: "Use https://example.com/known and https://example.org/new. Per https://en.wikipedia.org/wiki/Mercury_(element), done."})
+ if len(answerSources) != 3 {
+ t.Fatalf("expected answer URL sources, got %#v", answerSources)
+ }
+ sources := collector.sources()
+ if len(sources) != 3 {
+ t.Fatalf("expected canonical known + new sources, got %#v", sources)
+ }
+ if sources[2]["url"] != "https://en.wikipedia.org/wiki/Mercury_(element)" {
+ t.Fatalf("parenthesized URL was not preserved: %#v", sources[2])
+ }
+ answerAppearances, ok := sources[0]["appearances"].([]map[string]any)
+ if !ok || len(answerAppearances) != 2 || answerAppearances[1]["kind"] != "answer" || answerAppearances[1]["cited"] != true {
+ t.Fatalf("known source was not marked cited by final answer URL: %#v", sources[0])
+ }
+}
+
+func intPtr(value int) *int {
+ return &value
}
diff --git a/pkg/connector/stream_test.go b/pkg/connector/stream_test.go
index d934ee36..6f59d7c1 100644
--- a/pkg/connector/stream_test.go
+++ b/pkg/connector/stream_test.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
@@ -73,6 +74,50 @@ func TestStreamPublisherUsesFakeProviderAndPublishesDeltas(t *testing.T) {
}
}
+func TestStreamPublisherPublishesThinkingDeltasLiveAfterThinkingStart(t *testing.T) {
+ ctx := context.Background()
+ testAPI := ai.Api("test-stream-thinking")
+ ai.RegisterAPIProvider(testAPI, func(ctx context.Context, model ai.Model, llmContext ai.Context, options ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
+ stream := ai.NewAssistantMessageEventStream()
+ go func() {
+ message := ai.Message{Role: "assistant", Content: []ai.ContentBlock{{Type: "thinking", Thinking: "checking context"}, {Type: "text", Text: "answer"}}, StopReason: ai.StopReasonStop}
+ stream.Push(ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: 0, Partial: &message})
+ stream.Push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: 0, Delta: "checking context", Partial: &message})
+ stream.Push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: 1, Delta: "answer", Partial: &message})
+ stream.Push(ai.AssistantMessageEvent{Type: "thinking_end", ContentIndex: 0, Content: "checking context", Partial: &message})
+ stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonStop, Message: &message})
+ }()
+ return stream
+ })
+ defer ai.UnregisterAPIProvider(testAPI)
+
+ publisher := &recordingStreamPublisher{}
+ client := &Client{}
+ run := aistream.NewRun("run", "thread", "beeper/fake", "assistant:run", "Fake", timeNow())
+ run.MessageID = "assistant:run"
+ result := client.streamPublisher(publisher, "!room:example.com", "$event", run)(ctx, ai.Model{ID: "fake", API: testAPI}, ai.Context{}, ai.SimpleStreamOptions{}).Result()
+ if result.StopReason != ai.StopReasonStop {
+ t.Fatalf("unexpected stream result %#v", result)
+ }
+ var sawThinkingStart, sawThinkingDelta, sawTextDelta bool
+ for _, update := range publisher.updates {
+ payload, _ := update[aistream.BeeperAIKey].(aistream.BeeperAI)
+ for _, envelope := range payload.Events {
+ switch envelope.Event.Type() {
+ case agui.EventReasoningMsgStart:
+ sawThinkingStart = true
+ case agui.EventReasoningMsgCont:
+ sawThinkingDelta = envelope.Event.Get("delta") == "checking context"
+ case agui.EventTextMessageContent:
+ sawTextDelta = envelope.Event.Get("delta") == "answer"
+ }
+ }
+ }
+ if !sawThinkingStart || !sawThinkingDelta || !sawTextDelta {
+ t.Fatalf("expected live thinking and text carriers, start=%v thinking=%v text=%v updates=%#v", sawThinkingStart, sawThinkingDelta, sawTextDelta, publisher.updates)
+ }
+}
+
func TestStreamPublisherFailsAfterIdleTimeout(t *testing.T) {
ctx := context.Background()
oldTimeout := activeStreamIdleTimeout
@@ -201,6 +246,184 @@ func TestStreamPublisherReusesRunAcrossToolContinuation(t *testing.T) {
}
}
+func TestStreamPublisherUsesFreshTextMessageAfterToolContinuationWithPriorText(t *testing.T) {
+ ctx := context.Background()
+ toolAPI := ai.Api("test-stream-tool-prior-text")
+ answerAPI := ai.Api("test-stream-answer-after-prior-text")
+ ai.RegisterAPIProvider(toolAPI, func(ctx context.Context, model ai.Model, llmContext ai.Context, options ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
+ stream := ai.NewAssistantMessageEventStream()
+ go func() {
+ toolCall := &ai.ToolCall{ID: "call-session", Name: "get_session", Arguments: map[string]any{}}
+ message := ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{
+ {Type: "text", Text: "before "},
+ {Type: "toolCall", ID: toolCall.ID, Name: toolCall.Name, Arguments: toolCall.Arguments},
+ },
+ StopReason: ai.StopReasonToolUse,
+ }
+ stream.Push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: 0, Delta: "before "})
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_start", ContentIndex: 1, ToolCall: toolCall})
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_end", ContentIndex: 1, ToolCall: toolCall})
+ stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonToolUse, Message: &message})
+ }()
+ return stream
+ })
+ ai.RegisterAPIProvider(answerAPI, func(ctx context.Context, model ai.Model, llmContext ai.Context, options ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
+ stream := ai.NewAssistantMessageEventStream()
+ go func() {
+ message := ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{{Type: "text", Text: "after"}},
+ StopReason: ai.StopReasonStop,
+ }
+ stream.Push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: 0, Delta: "after"})
+ stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonStop, Message: &message})
+ }()
+ return stream
+ })
+ defer ai.UnregisterAPIProvider(toolAPI)
+ defer ai.UnregisterAPIProvider(answerAPI)
+
+ publisher := &recordingStreamPublisher{}
+ client := &Client{}
+ run := aistream.NewRun("run", "thread", "beeper/fake", "assistant:run", "Fake", timeNow())
+ run.MessageID = "assistant:run"
+ cursor := &streamPublishCursor{nextSeq: 1}
+
+ toolResult := client.streamPublisherWithEndFrom(publisher, "!room:example.com", "$event", run, cursor, nil)(ctx, ai.Model{ID: "fake", API: toolAPI}, ai.Context{}, ai.SimpleStreamOptions{}).Result()
+ if toolResult.StopReason != ai.StopReasonToolUse {
+ t.Fatalf("unexpected tool stream result %#v", toolResult)
+ }
+ writer := aistream.NewWriter(run, timeNow)
+ writer.ToolEnd("call-session", "get_session", map[string]any{}, map[string]any{"state": agui.ToolResultStateComplete, "status": "success"})
+
+ answerResult := client.streamPublisherWithEndFrom(publisher, "!room:example.com", "$event", run, cursor, nil)(ctx, ai.Model{ID: "fake", API: answerAPI}, ai.Context{}, ai.SimpleStreamOptions{}).Result()
+ if answerResult.StopReason != ai.StopReasonStop {
+ t.Fatalf("unexpected answer stream result %#v", answerResult)
+ }
+
+ textMessageIDs := textContentMessageIDs(run.Events)
+ if strings.Join(textMessageIDs, "|") != "assistant:run|assistant:run-text-1" {
+ t.Fatalf("resumed answer reused a prior text message id: %#v", textMessageIDs)
+ }
+ message := run.FinalBeeperAIMessage(0, true)
+ got := uiPartSummary(message.Parts)
+ want := []string{"text:before ", "tool-call:call-session", "text:after"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("continued UI parts mismatch\ngot: %#v\nwant: %#v\nparts: %#v", got, want, message.Parts)
+ }
+}
+
+func TestStreamPublisherAnchorsContinuationThinkingAndSecondToolAfterApproval(t *testing.T) {
+ ctx := context.Background()
+ toolAPI := ai.Api("test-stream-tool-prior-text-second-tool")
+ answerAPI := ai.Api("test-stream-answer-thinking-second-tool")
+ ai.RegisterAPIProvider(toolAPI, func(ctx context.Context, model ai.Model, llmContext ai.Context, options ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
+ stream := ai.NewAssistantMessageEventStream()
+ go func() {
+ toolCall := &ai.ToolCall{ID: "call-session", Name: "get_session", Arguments: map[string]any{}}
+ message := ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{
+ {Type: "text", Text: "before "},
+ {Type: "toolCall", ID: toolCall.ID, Name: toolCall.Name, Arguments: toolCall.Arguments},
+ },
+ StopReason: ai.StopReasonToolUse,
+ }
+ stream.Push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: 0, Delta: "before "})
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_start", ContentIndex: 1, ToolCall: toolCall})
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_end", ContentIndex: 1, ToolCall: toolCall})
+ stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonToolUse, Message: &message})
+ }()
+ return stream
+ })
+ ai.RegisterAPIProvider(answerAPI, func(ctx context.Context, model ai.Model, llmContext ai.Context, options ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
+ stream := ai.NewAssistantMessageEventStream()
+ go func() {
+ secondTool := &ai.ToolCall{ID: "call-second", Name: "second_tool", Arguments: map[string]any{"ok": true}}
+ message := ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{
+ {Type: "thinking", Thinking: "checking approval"},
+ {Type: "text", Text: "after "},
+ {Type: "toolCall", ID: secondTool.ID, Name: secondTool.Name, Arguments: secondTool.Arguments},
+ },
+ StopReason: ai.StopReasonToolUse,
+ }
+ stream.Push(ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: 0})
+ stream.Push(ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: 0, Delta: "checking approval"})
+ stream.Push(ai.AssistantMessageEvent{Type: "text_delta", ContentIndex: 1, Delta: "after "})
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_start", ContentIndex: 2, ToolCall: secondTool})
+ stream.Push(ai.AssistantMessageEvent{Type: "toolcall_end", ContentIndex: 2, ToolCall: secondTool})
+ stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonToolUse, Message: &message})
+ }()
+ return stream
+ })
+ defer ai.UnregisterAPIProvider(toolAPI)
+ defer ai.UnregisterAPIProvider(answerAPI)
+
+ publisher := &recordingStreamPublisher{}
+ client := &Client{}
+ run := aistream.NewRun("run", "thread", "beeper/fake", "assistant:run", "Fake", timeNow())
+ run.MessageID = "assistant:run"
+ cursor := &streamPublishCursor{nextSeq: 1}
+
+ toolResult := client.streamPublisherWithEndFrom(publisher, "!room:example.com", "$event", run, cursor, nil)(ctx, ai.Model{ID: "fake", API: toolAPI}, ai.Context{}, ai.SimpleStreamOptions{}).Result()
+ if toolResult.StopReason != ai.StopReasonToolUse {
+ t.Fatalf("unexpected first tool stream result %#v", toolResult)
+ }
+ writer := aistream.NewWriter(run, timeNow)
+ writer.ToolEnd("call-session", "get_session", map[string]any{}, map[string]any{"state": agui.ToolResultStateComplete, "status": "success"})
+
+ answerResult := client.streamPublisherWithEndFrom(publisher, "!room:example.com", "$event", run, cursor, nil)(ctx, ai.Model{ID: "fake", API: answerAPI}, ai.Context{}, ai.SimpleStreamOptions{}).Result()
+ if answerResult.StopReason != ai.StopReasonToolUse {
+ t.Fatalf("unexpected answer stream result %#v", answerResult)
+ }
+
+ parents := toolParentMessageIDs(run.Events)
+ if parents["call-second"] != "assistant:run-text-2" {
+ t.Fatalf("second continuation tool used wrong parent: %#v", parents)
+ }
+
+ message := run.FinalBeeperAIMessage(0, true)
+ got := uiPartSummary(message.Parts)
+ want := []string{"text:before ", "tool-call:call-session", "thinking:checking approval", "text:after ", "tool-call:call-second"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("continued UI parts mismatch\ngot: %#v\nwant: %#v\nparts: %#v", got, want, message.Parts)
+ }
+}
+
+func TestDoneFallbackAfterPriorTextUsesCurrentWriterOnly(t *testing.T) {
+ run := aistream.NewRun("run", "thread", "beeper/fake", "assistant:run", "Fake", timeNow())
+ run.MessageID = "assistant:run"
+ firstWriter := aistream.NewWriter(run, timeNow)
+ firstWriter.Start()
+ firstWriter.TextDelta(0, "before ")
+ firstWriter.ToolStart("call-session", "get_session", 1, nil)
+ firstWriter.ToolInputComplete("call-session", "get_session", map[string]any{})
+ firstWriter.AwaitToolUseWithUsage(nil)
+
+ message := ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{{Type: "text", Text: "after"}},
+ StopReason: ai.StopReasonStop,
+ }
+ secondWriter := aistream.NewWriter(run, timeNow)
+ applyAIStreamEvent(secondWriter, ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonStop, Message: &message})
+
+ textMessageIDs := textContentMessageIDs(run.Events)
+ if strings.Join(textMessageIDs, "|") != "assistant:run|assistant:run-text-1" {
+ t.Fatalf("done fallback reused a prior text message id: %#v", textMessageIDs)
+ }
+ uiMessage := run.FinalBeeperAIMessage(0, true)
+ got := uiPartSummary(uiMessage.Parts)
+ want := []string{"text:before ", "tool-call:call-session", "text:after"}
+ if strings.Join(got, "|") != strings.Join(want, "|") {
+ t.Fatalf("fallback UI parts mismatch\ngot: %#v\nwant: %#v\nparts: %#v", got, want, uiMessage.Parts)
+ }
+}
+
func TestFinalizedAssistantRunPreservesAccumulatedStreamUsage(t *testing.T) {
run := *aistream.NewRun("run", "thread", "beeper/fake", "assistant:run", "Fake", timeNow())
run.Usage = agui.Usage{PromptTokens: 13, CompletionTokens: 3, ReasoningTokens: 3, TotalTokens: 16}
@@ -589,34 +812,25 @@ func TestAppendToolOutputsPreservesStructuredResult(t *testing.T) {
}
}
-func TestAppendToolOutputsAddsWebSearchSources(t *testing.T) {
+func TestAppendToolOutputsAddsFetchSources(t *testing.T) {
run := aistream.NewRun("run", "thread", "beeper/gpt-5", "assistant:run", "GPT-5", timeNow())
run.MessageID = "assistant:run"
writer := aistream.NewWriter(run, timeNow)
- writer.ToolStart("call-search", "web_search", 0, nil)
- writer.ToolEnd("call-search", "web_search", map[string]any{"query": "q"}, nil)
+ writer.ToolStart("call-fetch", "fetch", 0, nil)
+ writer.ToolEnd("call-fetch", "fetch", map[string]any{"url": "https://example.com/one"}, nil)
appendToolOutputs(run, []toolOutputEvent{{
- ID: "call-search",
- Name: "web_search",
- Input: map[string]any{"query": "q"},
+ ID: "call-fetch",
+ Name: "fetch",
+ Input: map[string]any{"url": "https://example.com/one"},
Result: agent.AgentToolResult[any]{
Details: map[string]any{
- "results": []any{
- map[string]any{
- "id": "doc_1",
- "title": "One",
- "url": "https://example.com/one",
- "description": "desc",
- "published": "2026-01-01",
- "siteName": "Example",
- "highlights": []any{"hit"},
- "highlightScores": []any{0.5},
- "summary": "sum",
- "subpages": []any{map[string]any{"title": "Sub", "url": "https://example.com/sub"}},
- "extras": map[string]any{"links": []any{"https://example.com/link"}},
- },
- },
+ "title": "One",
+ "url": "https://example.com/one",
+ "final_url": "https://example.com/one",
+ "description": "desc",
+ "published_at": "2026-01-01",
+ "site_name": "Example",
},
},
}})
@@ -639,11 +853,55 @@ func TestAppendToolOutputsAddsWebSearchSources(t *testing.T) {
t.Fatalf("missing canonical source metadata: %#v", source)
}
appearances, ok := source["appearances"].([]map[string]any)
- if !ok || len(appearances) != 1 || appearances[0]["kind"] != "web_search" || appearances[0]["toolCallId"] != "call-search" || appearances[0]["rank"] != 1 {
+ if !ok || len(appearances) != 1 || appearances[0]["kind"] != "fetch" || appearances[0]["toolCallId"] != "call-fetch" {
t.Fatalf("missing source appearances: %#v", source["appearances"])
}
}
+func TestAppendToolOutputsKeepsWebSearchResultSources(t *testing.T) {
+ run := aistream.NewRun("run", "thread", "beeper/gpt-5", "assistant:run", "GPT-5", timeNow())
+ run.MessageID = "assistant:run"
+ writer := aistream.NewWriter(run, timeNow)
+ writer.ToolStart("call-search", "web_search", 0, nil)
+ writer.ToolEnd("call-search", "web_search", map[string]any{"query": "latest news"}, nil)
+
+ appendToolOutputs(run, []toolOutputEvent{{
+ ID: "call-search",
+ Name: "web_search",
+ Input: map[string]any{"query": "latest news"},
+ Result: agent.AgentToolResult[any]{
+ Details: map[string]any{
+ "results": []any{
+ map[string]any{
+ "title": "Headline",
+ "url": "https://example.com/headline",
+ "description": "desc",
+ },
+ },
+ },
+ },
+ }})
+
+ message := run.FinalBeeperAIMessage(0, true)
+ var source map[string]any
+ for _, part := range message.Parts {
+ if part["type"] == "source-url" {
+ source = part
+ break
+ }
+ }
+ if source == nil {
+ t.Fatalf("expected web_search source-url part for tool result list, got %#v", message.Parts)
+ }
+ appearances, ok := source["appearances"].([]map[string]any)
+ if !ok || len(appearances) != 1 || appearances[0]["kind"] != "web_search" || appearances[0]["toolCallId"] != "call-search" || appearances[0]["rank"] != 1 {
+ t.Fatalf("missing web_search source appearance: %#v", source["appearances"])
+ }
+ if appearances[0]["cited"] == true {
+ t.Fatalf("raw web_search result should not be marked cited: %#v", appearances[0])
+ }
+}
+
func TestAssistantEventMetadataCanBeFinalizedBeforeInsert(t *testing.T) {
client := &Client{}
run := aistream.NewRun("run", "thread", "beeper/gpt-5", "assistant:run", "GPT-5", timeNow())
@@ -722,7 +980,7 @@ func TestAssistantModelProfileUsesCatalogDisplayName(t *testing.T) {
if r.URL.Path != "/models" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
- _, _ = w.Write([]byte(`{"type":"com.beeper.ai.model_list","data":[{"id":"openai/gpt-5.5","name":"GPT 5.5 Catalog","provider":{"id":"wpcom_openai","model_id":"gpt-5.5","api":"openai-responses"}}]}`))
+ _, _ = w.Write([]byte(`{"type":"com.beeper.ai.model_list","data":[{"id":"openai/gpt-5.5","name":"GPT 5.5 Catalog","runtime":{"provider":"openai","model":"gpt-5.5","api":"openai-responses","base_url":"/proxy/openai/v1"}}]}`))
}))
defer server.Close()
@@ -735,7 +993,7 @@ func TestAssistantModelProfileUsesCatalogDisplayName(t *testing.T) {
DisplayName: "Beeper AI",
API: ai.ApiOpenAIResponses,
Provider: ai.ProviderOpenAI,
- BaseURL: server.URL + "/proxy/openai/v1",
+ BaseURL: server.URL,
DefaultModel: "beeper/default",
}}},
}},
@@ -817,6 +1075,49 @@ func TestApplyAIStreamEventStreamsToolCallsFromPartialContent(t *testing.T) {
}
}
+func TestApplyAIStreamEventStreamsNativeToolResult(t *testing.T) {
+ run := aistream.NewRun("run", "thread", "beeper/gpt-5.5", "assistant:run", "GPT-5.5", timeNow())
+ run.MessageID = "assistant:run"
+ writer := aistream.NewWriter(run, timeNow)
+ toolCall := &ai.ToolCall{
+ ID: "ws_1",
+ Name: "web_search",
+ Arguments: map[string]any{"query": "latest headlines Amsterdam news today"},
+ }
+
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{Type: "toolcall_start", ToolCall: toolCall})
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{
+ Type: "toolresult",
+ ToolCall: toolCall,
+ CustomValue: map[string]any{
+ "state": agui.ToolResultStateComplete,
+ "status": "success",
+ "provider": "openai",
+ "native": true,
+ },
+ })
+
+ var sawEnd, sawResult bool
+ for _, evt := range run.Events {
+ switch evt.Type() {
+ case agui.EventToolCallEnd:
+ sawEnd = evt.Get("toolCallId") == "ws_1" && evt.Get("toolName") == "web_search"
+ case agui.EventToolCallResult:
+ sawResult = evt.Get("toolCallId") == "ws_1"
+ var output map[string]any
+ if err := json.Unmarshal([]byte(evt.Get("content").(string)), &output); err != nil {
+ t.Fatalf("native tool result content is not JSON: %v", err)
+ }
+ if output["status"] != "success" || output["native"] != true {
+ t.Fatalf("unexpected native tool result output: %#v", output)
+ }
+ }
+ }
+ if !sawEnd || !sawResult {
+ t.Fatalf("missing native tool result lifecycle events: %#v", run.Events)
+ }
+}
+
func TestApplyAIStreamEventSkipsEmptyToolCallDelta(t *testing.T) {
run := aistream.NewRun("run", "thread", "beeper/gpt-5.5", "assistant:run", "GPT-5.5", timeNow())
run.MessageID = "assistant:run"
@@ -868,6 +1169,57 @@ func TestApplyAIStreamEventDoesNotPublishReasoningSignaturesToAGUI(t *testing.T)
}
}
+func TestApplyAIStreamEventPublishesReasoningContentFromEndSnapshot(t *testing.T) {
+ run := aistream.NewRun("run", "thread", "beeper/gpt-5.5", "assistant:run", "GPT-5.5", timeNow())
+ writer := aistream.NewWriter(run, timeNow)
+ partial := &ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{{
+ Type: "thinking",
+ Thinking: "final summary",
+ }},
+ }
+
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: 0, Partial: partial})
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{Type: "thinking_end", ContentIndex: 0, Content: "final summary", Partial: partial})
+
+ var reasoningContent []string
+ for _, evt := range run.Events {
+ if evt.Type() == agui.EventReasoningMsgCont {
+ reasoningContent = append(reasoningContent, fmt.Sprint(evt.Get("delta")))
+ }
+ }
+ if strings.Join(reasoningContent, "") != "final summary" {
+ t.Fatalf("expected thinking_end content to be emitted before end, got events=%#v", run.Events)
+ }
+}
+
+func TestApplyAIStreamEventDoesNotDuplicateReasoningEndSnapshot(t *testing.T) {
+ run := aistream.NewRun("run", "thread", "beeper/gpt-5.5", "assistant:run", "GPT-5.5", timeNow())
+ writer := aistream.NewWriter(run, timeNow)
+ partial := &ai.Message{
+ Role: "assistant",
+ Content: []ai.ContentBlock{{
+ Type: "thinking",
+ Thinking: "hidden continuity",
+ }},
+ }
+
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{Type: "thinking_start", ContentIndex: 0, Partial: partial})
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{Type: "thinking_delta", ContentIndex: 0, Delta: "hidden continuity", Partial: partial})
+ applyAIStreamEvent(writer, ai.AssistantMessageEvent{Type: "thinking_end", ContentIndex: 0, Content: "hidden continuity", Partial: partial})
+
+ var reasoningContent []string
+ for _, evt := range run.Events {
+ if evt.Type() == agui.EventReasoningMsgCont {
+ reasoningContent = append(reasoningContent, fmt.Sprint(evt.Get("delta")))
+ }
+ }
+ if strings.Join(reasoningContent, "") != "hidden continuity" || len(reasoningContent) != 1 {
+ t.Fatalf("expected reasoning content once, got %#v events=%#v", reasoningContent, run.Events)
+ }
+}
+
func TestApplyAIStreamEventIgnoresRawProviderEvent(t *testing.T) {
run := aistream.NewRun("run", "thread", "beeper/gpt-5.5", "assistant:run", "GPT-5.5", timeNow())
writer := aistream.NewWriter(run, timeNow)
@@ -910,6 +1262,9 @@ func TestPublishToolOutputStreamsLiveResult(t *testing.T) {
if len(publisher.updates) != 1 {
t.Fatalf("expected one live stream update, got %#v", publisher.updates)
}
+ if len(active.streams) != 1 || len(active.streams[0].tools) != 1 || active.streams[0].tools[0].ID != "call-1" {
+ t.Fatalf("live tool output was not retained for finalization: %#v", active.streams)
+ }
aiPayload, ok := publisher.updates[0][aistream.BeeperAIKey].(aistream.BeeperAI)
if !ok || len(aiPayload.Events) != 2 {
t.Fatalf("unexpected live stream carrier %#v", publisher.updates[0])
@@ -924,6 +1279,72 @@ func TestPublishToolOutputStreamsLiveResult(t *testing.T) {
}
}
+func TestStreamPublisherSkipsDuplicateToolResultSourcesAfterLiveToolOutput(t *testing.T) {
+ ctx := context.Background()
+ testAPI := ai.Api("test-duplicate-toolresult-sources")
+ toolCall := &ai.ToolCall{
+ ID: "call-fetch",
+ Name: "fetch",
+ Arguments: map[string]any{"url": "https://example.com"},
+ }
+ result := map[string]any{
+ "url": "https://example.com",
+ "title": "Example",
+ "description": "Example source",
+ }
+ ai.RegisterAPIProvider(testAPI, func(ctx context.Context, model ai.Model, llmContext ai.Context, options ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
+ stream := ai.NewAssistantMessageEventStream()
+ go func() {
+ stream.Push(ai.AssistantMessageEvent{Type: "toolresult", ToolCall: toolCall, CustomValue: result})
+ stream.Push(ai.AssistantMessageEvent{Type: "done", Reason: ai.StopReasonStop, Message: &ai.Message{Role: "assistant", StopReason: ai.StopReasonStop}})
+ }()
+ return stream
+ })
+ defer ai.UnregisterAPIProvider(testAPI)
+
+ publisher := &recordingStreamPublisher{}
+ run := aistream.NewRun("run", "thread", "beeper/gpt-5.5", "assistant:run", "GPT-5.5", timeNow())
+ run.MessageID = "assistant:run"
+ writer := aistream.NewWriter(run, timeNow)
+ writer.Start()
+ writer.ToolStart(toolCall.ID, toolCall.Name, 0, nil)
+ active := &activeAIRun{streams: []*assistantStreamState{{
+ eventID: "$event",
+ run: run,
+ sources: newSourceCollector(),
+ publish: streamPublishCursor{
+ nextSeq: 1,
+ published: len(run.Events),
+ started: true,
+ },
+ }}}
+
+ err := active.publishToolOutput(ctx, &Client{}, publisher, "!room:example.com", toolOutputEvent{
+ ID: toolCall.ID,
+ Name: toolCall.Name,
+ Input: toolCall.Arguments,
+ Result: agent.AgentToolResult[any]{
+ Details: result,
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ sourceEventsBefore := sourceCustomEventCount(run.Events)
+ if sourceEventsBefore != 1 {
+ t.Fatalf("expected live tool output to publish one source, got %d events=%#v", sourceEventsBefore, run.Events)
+ }
+
+ stream := active.streams[0]
+ response := (&Client{}).streamPublisherWithEndFrom(publisher, "!room:example.com", "$event", run, &stream.publish, nil)(ctx, ai.Model{ID: "fake", API: testAPI}, ai.Context{}, ai.SimpleStreamOptions{}).Result()
+ if response.StopReason != ai.StopReasonStop {
+ t.Fatalf("unexpected stream response %#v", response)
+ }
+ if sourceEventsAfter := sourceCustomEventCount(run.Events); sourceEventsAfter != sourceEventsBefore {
+ t.Fatalf("duplicate toolresult re-emitted sources: before=%d after=%d events=%#v", sourceEventsBefore, sourceEventsAfter, run.Events)
+ }
+}
+
func TestPublishNewStreamEventsSuppressesMautrixRequestBodyLogs(t *testing.T) {
logger := zerolog.New(io.Discard).Level(zerolog.DebugLevel)
ctx := logger.WithContext(context.Background())
@@ -946,6 +1367,65 @@ func TestPublishNewStreamEventsSuppressesMautrixRequestBodyLogs(t *testing.T) {
}
}
+func textContentMessageIDs(events []agui.Event) []string {
+ var ids []string
+ for _, evt := range events {
+ if evt.Type() != agui.EventTextMessageContent && evt.Type() != agui.EventTextMessageChunk {
+ continue
+ }
+ delta, _ := evt.Get("delta").(string)
+ if delta == "" {
+ continue
+ }
+ messageID, _ := evt.Get("messageId").(string)
+ ids = append(ids, messageID)
+ }
+ return ids
+}
+
+func toolParentMessageIDs(events []agui.Event) map[string]string {
+ parents := map[string]string{}
+ for _, evt := range events {
+ if evt.Type() != agui.EventToolCallStart {
+ continue
+ }
+ parents[firstNonEmptyString(evt.Get("toolCallId"))] = firstNonEmptyString(evt.Get("parentMessageId"))
+ }
+ return parents
+}
+
+func sourceCustomEventCount(events []agui.Event) int {
+ count := 0
+ for _, evt := range events {
+ if evt.Type() == agui.EventCustom && evt.Get("name") == "com.beeper.source" {
+ count++
+ }
+ }
+ return count
+}
+
+func uiPartSummary(parts []aistream.MessagePart) []string {
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ switch part["type"] {
+ case "text", "thinking":
+ out = append(out, fmt.Sprintf("%s:%s", part["type"], part["content"]))
+ case "tool-call":
+ out = append(out, fmt.Sprintf("tool-call:%s", firstNonEmptyString(part["toolCallId"], part["id"])))
+ }
+ }
+ return out
+}
+
+func firstNonEmptyString(values ...any) string {
+ for _, value := range values {
+ if text, _ := value.(string); text != "" {
+ return text
+ }
+ }
+ return ""
+}
+
func testAIStore(t *testing.T) *aidb.Store {
t.Helper()
rawDB, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "bridge.db"))
diff --git a/pkg/connector/timezone.go b/pkg/connector/timezone.go
new file mode 100644
index 00000000..4c3a002f
--- /dev/null
+++ b/pkg/connector/timezone.go
@@ -0,0 +1,80 @@
+package connector
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/rs/zerolog"
+ "maunium.net/go/mautrix/bridgev2"
+
+ "github.com/beeper/ai-bridge/pkg/aiid"
+)
+
+const beeperTimezoneKey = "com.beeper.tz"
+
+func (cl *Client) updateLastKnownTimezoneFromMessage(ctx context.Context, msg *bridgev2.MatrixMessage) {
+ timezone, ok := lastKnownTimezoneFromMatrixMessage(msg)
+ if !ok || cl == nil || cl.UserLogin == nil {
+ return
+ }
+ if setLastKnownTimezoneOnLogin(cl.UserLogin, timezone) {
+ if err := cl.UserLogin.Save(ctx); err != nil {
+ zerolog.Ctx(ctx).Warn().Err(err).Str("timezone", timezone).Msg("Failed to save last known timezone")
+ }
+ }
+}
+
+func (cl *Client) lastKnownTimezone() string {
+ if cl == nil || cl.UserLogin == nil {
+ return ""
+ }
+ meta, ok := cl.UserLogin.Metadata.(*aiid.UserLoginMetadata)
+ if !ok || meta == nil {
+ return ""
+ }
+ return meta.LastKnownTimezone
+}
+
+func setLastKnownTimezoneOnLogin(login *bridgev2.UserLogin, timezone string) bool {
+ if login == nil || timezone == "" {
+ return false
+ }
+ meta, ok := login.Metadata.(*aiid.UserLoginMetadata)
+ if !ok || meta == nil {
+ meta = &aiid.UserLoginMetadata{}
+ login.Metadata = meta
+ }
+ if meta.LastKnownTimezone == timezone {
+ return false
+ }
+ meta.LastKnownTimezone = timezone
+ return true
+}
+
+func lastKnownTimezoneFromMatrixMessage(msg *bridgev2.MatrixMessage) (string, bool) {
+ if msg == nil || msg.Event == nil || msg.Event.Content.Raw == nil {
+ return "", false
+ }
+ raw, ok := msg.Event.Content.Raw[beeperTimezoneKey].(string)
+ if !ok {
+ return "", false
+ }
+ timezone, ok := normalizeLastKnownTimezone(raw)
+ return timezone, ok
+}
+
+func normalizeLastKnownTimezone(raw string) (string, bool) {
+ timezone := strings.TrimSpace(raw)
+ if timezone == "" || strings.EqualFold(timezone, "local") {
+ return "", false
+ }
+ if strings.EqualFold(timezone, "utc") {
+ timezone = "UTC"
+ }
+ loc, err := time.LoadLocation(timezone)
+ if err != nil {
+ return "", false
+ }
+ return loc.String(), true
+}
diff --git a/pkg/connector/timezone_test.go b/pkg/connector/timezone_test.go
new file mode 100644
index 00000000..0e5c64dd
--- /dev/null
+++ b/pkg/connector/timezone_test.go
@@ -0,0 +1,44 @@
+package connector
+
+import (
+ "testing"
+
+ "maunium.net/go/mautrix/bridgev2"
+ "maunium.net/go/mautrix/bridgev2/database"
+ "maunium.net/go/mautrix/event"
+
+ "github.com/beeper/ai-bridge/pkg/aiid"
+)
+
+func TestLastKnownTimezoneFromMatrixMessage(t *testing.T) {
+ msg := &bridgev2.MatrixMessage{
+ MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{
+ Event: &event.Event{Content: event.Content{Raw: map[string]any{
+ beeperTimezoneKey: " Europe/Amsterdam ",
+ }}},
+ },
+ }
+ timezone, ok := lastKnownTimezoneFromMatrixMessage(msg)
+ if !ok || timezone != "Europe/Amsterdam" {
+ t.Fatalf("timezone = %q ok=%v, want Europe/Amsterdam true", timezone, ok)
+ }
+
+ msg.Event.Content.Raw[beeperTimezoneKey] = "Local"
+ if timezone, ok = lastKnownTimezoneFromMatrixMessage(msg); ok || timezone != "" {
+ t.Fatalf("Local timezone should be rejected, got %q ok=%v", timezone, ok)
+ }
+}
+
+func TestSetLastKnownTimezoneOnLogin(t *testing.T) {
+ login := &bridgev2.UserLogin{UserLogin: &database.UserLogin{}}
+ if !setLastKnownTimezoneOnLogin(login, "Europe/Amsterdam") {
+ t.Fatal("expected timezone update")
+ }
+ meta, ok := login.Metadata.(*aiid.UserLoginMetadata)
+ if !ok || meta.LastKnownTimezone != "Europe/Amsterdam" {
+ t.Fatalf("timezone was not stored in login metadata: %#v", login.Metadata)
+ }
+ if setLastKnownTimezoneOnLogin(login, "Europe/Amsterdam") {
+ t.Fatal("unchanged timezone should not require a save")
+ }
+}
diff --git a/pkg/connector/tool_modes.go b/pkg/connector/tool_modes.go
new file mode 100644
index 00000000..046011a1
--- /dev/null
+++ b/pkg/connector/tool_modes.go
@@ -0,0 +1,60 @@
+package connector
+
+import "strings"
+
+const (
+ toolModeOff = "off"
+ toolModeBeeper = "beeper"
+ toolModeNative = "native"
+
+ defaultSearchMode = toolModeBeeper
+ defaultFetchMode = toolModeBeeper
+)
+
+func roomSearchMode(config RoomConfig) string {
+ if config.SearchMode != "" {
+ return normalizedToolMode(config.SearchMode, defaultSearchMode)
+ }
+ return searchModeFromDisabled(config.DisabledTools)
+}
+
+func roomFetchMode(config RoomConfig) string {
+ if config.FetchMode != "" {
+ return normalizedToolMode(config.FetchMode, defaultFetchMode)
+ }
+ return fetchModeFromDisabled(config.DisabledTools)
+}
+
+func normalizedToolMode(value string, fallback string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case toolModeOff, toolModeBeeper, toolModeNative:
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return fallback
+ }
+}
+
+func searchModeFromDisabled(disabled []string) string {
+ if toolDisabled(disabled, "web_search") || toolDisabled(disabled, "search") {
+ return toolModeOff
+ }
+ return defaultSearchMode
+}
+
+func fetchModeFromDisabled(disabled []string) string {
+ if toolDisabled(disabled, "fetch") {
+ return toolModeOff
+ }
+ return defaultFetchMode
+}
+
+func toolModeStateContent(config RoomConfig) map[string]any {
+ content := map[string]any{
+ "search": roomSearchMode(config),
+ "fetch": roomFetchMode(config),
+ }
+ if len(config.DisabledTools) > 0 {
+ content["disabled"] = config.DisabledTools
+ }
+ return content
+}
diff --git a/scripts/generate-image-models-go.mjs b/scripts/generate-image-models-go.mjs
deleted file mode 100755
index 08c145e6..00000000
--- a/scripts/generate-image-models-go.mjs
+++ /dev/null
@@ -1,21 +0,0 @@
-import fs from 'node:fs';
-import vm from 'node:vm';
-
-const sourcePath = process.argv[2] ?? '../pi/packages/ai/src/image-models.generated.ts';
-const outputPath = process.argv[3] ?? 'pkg/ai/image_models_generated.go';
-const input = fs.readFileSync(sourcePath, 'utf8');
-let code = input
- .replace(/^\s*\/\/.*$/gm, '')
- .replace(/import type[^;]+;\s*/g, '')
- .replace(/export const IMAGE_MODELS\s*=\s*/, 'const IMAGE_MODELS = ')
- .replace(/\s+satisfies\s+ImagesModel<[^>]+>/g, '')
- .replace(/\s+as const satisfies Record>>;?/, ';');
-code += '\nIMAGE_MODELS;';
-const models = vm.runInNewContext(code, {});
-const json = JSON.stringify(models).replace(/`/g, '`+"`"+`');
-const providerOrder = Object.keys(models);
-const idOrder = Object.fromEntries(Object.entries(models).map(([provider, providerModels]) => [provider, Object.keys(providerModels)]));
-const providerOrderGo = providerOrder.map((provider) => `\t${JSON.stringify(provider)},`).join('\n');
-const idOrderJSON = JSON.stringify(idOrder).replace(/`/g, '`+"`"+`');
-const out = `package ai\n\nimport \"encoding/json\"\n\nvar imageModelsJSON = \`${json}\`\n\nvar imageModelProviderOrder = []ImagesProvider{\n${providerOrderGo}\n}\n\nvar imageModelIDOrderJSON = \`${idOrderJSON}\`\n\nvar ImageModels = mustLoadImageModels()\nvar imageModelIDOrder = mustLoadImageModelIDOrder()\n\nfunc mustLoadImageModels() map[ImagesProvider]map[string]ImagesModel {\n\tvar raw map[ImagesProvider]map[string]ImagesModel\n\tif err := json.Unmarshal([]byte(imageModelsJSON), &raw); err != nil {\n\t\tpanic(err)\n\t}\n\treturn raw\n}\n\nfunc mustLoadImageModelIDOrder() map[ImagesProvider][]string {\n\tvar raw map[ImagesProvider][]string\n\tif err := json.Unmarshal([]byte(imageModelIDOrderJSON), &raw); err != nil {\n\t\tpanic(err)\n\t}\n\treturn raw\n}\n`;
-fs.writeFileSync(outputPath, out);