Skip to content

Concepts · ai

OpenRouter Dialect

Reuses the OpenAI Responses adapter for OpenRouter via a dialect flag, translating server-tool naming and SSE events

Read this when
  • 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:*" → EventToolStart

Why 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.