Concepts · ai
OpenRouter Dialect
Reuses the OpenAI Responses adapter for OpenRouter via a dialect flag, translating server-tool naming and SSE events
- Wiring OpenRouter as a provider in pi-go
- Debugging why a server tool succeeds on OpenAI but not on OpenRouter
- Registering two dialects from one provider package
This page gives the OpenRouter dialect that reuses the OpenAI Responses adapter. OpenRouter exposes a Responses-API-shaped endpoint at
https://openrouter.ai/api/v1/responses that accepts every model in its
catalog (OpenAI, Anthropic, Google, Llama, and more). The shape is ~90% identical
to OpenAI’s native Responses API. pi-go reuses
pkg/ai/provider/openairesponses
for both, switched by a dialect flag set at construction time.
NewForOpenRouter(...) │ ▼ Provider{dialect: DialectOpenRouter} │ ├── buildParams ──► params.Tools is left empty │ ├── StreamText ──► option.WithJSONSet("tools", openrouter-shaped) │ overwrites the body's tools array │ └── SSE switch ──► response.content_part.delta → EventTextDelta response.reasoning.delta → EventThinkDelta response.reasoning_text.delta → EventThinkDelta response.reasoning.done → EventThinkEnd response.reasoning_text.done → EventThinkEnd response.output_item.added with type "openrouter:*" → EventToolStartWhy a dialect flag, not a separate package
OpenRouter’s divergences from OpenAI Responses are surgical. They include server-tool naming in the openrouter:* namespace and a smaller SSE event taxonomy. Incremental text uses response.content_part.delta instead of response.output_text.delta. Reasoning uses response.reasoning.delta and response.reasoning.done. Some routed models use response.reasoning_text.delta and response.reasoning_text.done. The adapter maps both pairs instead of assuming one upstream event taxonomy.
Forking the entire adapter duplicates message conversion, parameter building, tool choice mapping, and streaming bookkeeping. Those parts are identical across the two APIs. A flag on the existing Provider is the smallest change that captures the gap honestly.
Tool naming
OpenRouter’s typed union responses.ToolUnionParam cannot express the openrouter:* shape. The dialect sidesteps the SDK’s typed tools entirely. convertOpenRouterTools
emits a plain []map[string]any, and StreamText injects it into the
request body via
option.WithJSONSet("tools", ...).
Three server-tool types map cleanly today: web_search, web_fetch, and datetime. The rest are silently skipped per the existing convention.
On the way back, the SSE pipeline rewrites the raw provider item type to the canonical ai.ToolInfo.Name that the caller registered. Examples include openrouter:web_search and web_search_call. Server-tool calls persist with the same Name shape as function tools. Best-effort scrapes of the raw item payload populate ToolCall.Arguments, including query, url, and timezone. They also populate a one-line ServerToolOutput.Content summary, such as search: anthropic news or fetch: https://.... The verbatim provider JSON always remains on ServerToolOutput.Raw for richer extraction.
Registration identity is explicit
The dialect is a transport-level detail. Both constructors are in the openairesponses package. The package exports openairesponses.ID as its default identity. A catalog registration can use a different identity for each instance:
p := openairesponses.NewForOpenRouter(opts...)cat.RegisterTextProvider("openrouter", p, openRouterModels...)The explicit id keeps OpenAI and OpenRouter in separate catalog namespaces.
Recording cassettes
End-to-end tests live in
openrouter_test.go
and depend on httprr cassettes. Record once with the live API:
OPENROUTER_API_KEY=... go test -httprecord=TestOpenRouter \ ./pkg/ai/provider/openairesponses/Subsequent runs replay the cassettes deterministically. Missing cassettes cause the tests to skip with a recording hint.