Skip to content

Commit 4775708

Browse files
committed
Add OpenRouter provider routing params
1 parent 5d77512 commit 4775708

7 files changed

Lines changed: 224 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111
- CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.
1212
- Agents/config: allow `agents.list[].experimental.localModelLean` so lean local-model mode can be enabled for one configured agent instead of globally.
1313
- Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.
14+
- Providers/OpenRouter: honor provider-level `params.provider` routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.
1415

1516
### Fixes
1617

docs/gateway/config-agents.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,14 +427,15 @@ Time format in system prompt. Default: `auto` (OS preference).
427427
- `reasoningDefault`: default reasoning visibility for agents. Values: `"off"`, `"on"`, `"stream"`. Per-agent `agents.list[].reasoningDefault` overrides this default. Configured reasoning defaults are only applied for owners, authorized senders, or operator-admin gateway contexts when no per-message or session reasoning override is set.
428428
- `elevatedDefault`: default elevated-output level for agents. Values: `"off"`, `"on"`, `"ask"`, `"full"`. Default: `"on"`.
429429
- `model.primary`: format `provider/model` (e.g. `openai/gpt-5.5` for OpenAI API-key or Codex OAuth access). If you omit the provider, OpenClaw tries an alias first, then a unique configured-provider match for that exact model id, and only then falls back to the configured default provider (deprecated compatibility behavior, so prefer explicit `provider/model`). If that provider no longer exposes the configured default model, OpenClaw falls back to the first configured provider/model instead of surfacing a stale removed-provider default.
430-
- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`, `responsesServerCompaction`, `responsesCompactThreshold`, `chat_template_kwargs`, `extra_body`/`extraBody`).
430+
- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`, `responsesServerCompaction`, `responsesCompactThreshold`, OpenRouter `provider` routing, `chat_template_kwargs`, `extra_body`/`extraBody`).
431431
- Use `provider/*` entries such as `"openai-codex/*": {}` or `"vllm/*": {}` to show all discovered models for selected providers without manually listing every model id.
432432
- Add `agentRuntime` to a `provider/*` entry when every dynamically discovered model for that provider should use the same runtime. Exact `provider/model` runtime policy still wins over the wildcard.
433433
- Safe edits: use `openclaw config set agents.defaults.models '<json>' --strict-json --merge` to add entries. `config set` refuses replacements that would remove existing allowlist entries unless you pass `--replace`.
434434
- Provider-scoped configure/onboarding flows merge selected provider models into this map and preserve unrelated providers already configured.
435435
- For direct OpenAI Responses models, server-side compaction is enabled automatically. Use `params.responsesServerCompaction: false` to stop injecting `context_management`, or `params.responsesCompactThreshold` to override the threshold. See [OpenAI server-side compaction](/providers/openai#server-side-compaction-responses-api).
436436
- `params`: global default provider parameters applied to all models. Set at `agents.defaults.params` (e.g. `{ cacheRetention: "long" }`).
437437
- `params` merge precedence (config): `agents.defaults.params` (global base) is overridden by `agents.defaults.models["provider/model"].params` (per-model), then `agents.list[].params` (matching agent id) overrides by key. See [Prompt Caching](/reference/prompt-caching) for details.
438+
- `models.providers.openrouter.params.provider`: OpenRouter-wide default provider-routing policy. OpenClaw forwards this to OpenRouter's request `provider` object; per-model `agents.defaults.models["openrouter/<model>"].params.provider` and agent params override by key. See [OpenRouter provider routing](/providers/openrouter#advanced-configuration).
438439
- `params.extra_body`/`params.extraBody`: advanced pass-through JSON merged into `api: "openai-completions"` request bodies for OpenAI-compatible proxies. If it collides with generated request keys, the extra body wins; non-native completions routes still strip OpenAI-only `store` afterward.
439440
- `params.chat_template_kwargs`: vLLM/OpenAI-compatible chat-template arguments merged into top-level `api: "openai-completions"` request bodies. For `vllm/nemotron-3-*` with thinking off, the bundled vLLM plugin automatically sends `enable_thinking: false` and `force_nonempty_content: true`; explicit `chat_template_kwargs` override generated defaults, and `extra_body.chat_template_kwargs` still has final precedence. For vLLM Qwen thinking controls, set `params.qwenThinkingFormat` to `"chat-template"` or `"top-level"` on that model entry.
440441
- `compat.thinkingFormat`: OpenAI-compatible thinking payload style. Use `"together"` for Together-style `reasoning.enabled`, `"qwen"` for Qwen-style top-level `enable_thinking`, or `"qwen-chat-template"` for `chat_template_kwargs.enable_thinking` on Qwen-family backends that support request-level chat-template kwargs, such as vLLM. OpenClaw maps disabled thinking to `false` and enabled thinking to `true`.

docs/providers/openrouter.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,58 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers
282282
</Accordion>
283283

284284
<Accordion title="Provider routing metadata">
285-
If you pass OpenRouter provider routing under model params, OpenClaw forwards
286-
it as OpenRouter routing metadata before the shared stream wrappers run.
285+
OpenRouter supports a `provider` request object for underlying provider
286+
routing. Configure a default policy for all OpenRouter text-model requests
287+
with `models.providers.openrouter.params.provider`:
288+
289+
```json5
290+
{
291+
models: {
292+
providers: {
293+
openrouter: {
294+
params: {
295+
provider: {
296+
sort: "latency",
297+
require_parameters: true,
298+
data_collection: "deny",
299+
},
300+
},
301+
},
302+
},
303+
},
304+
}
305+
```
306+
307+
OpenClaw forwards that object to OpenRouter as the request `provider`
308+
payload. Use OpenRouter's documented snake_case fields, including `sort`,
309+
`only`, `ignore`, `order`, `allow_fallbacks`, `require_parameters`,
310+
`data_collection`, `quantizations`, `max_price`, `preferred_max_latency`,
311+
`preferred_min_throughput`, `zdr`, and `enforce_distillable_text`.
312+
313+
Per-model params still override the provider-wide routing object:
314+
315+
```json5
316+
{
317+
agents: {
318+
defaults: {
319+
models: {
320+
"openrouter/anthropic/claude-sonnet-4-6": {
321+
params: {
322+
provider: {
323+
order: ["anthropic"],
324+
allow_fallbacks: false,
325+
},
326+
},
327+
},
328+
},
329+
},
330+
},
331+
}
332+
```
333+
334+
This only applies on OpenRouter chat-completions routes. Direct Anthropic,
335+
Google, OpenAI, or custom provider routes ignore OpenRouter routing params.
336+
287337
</Accordion>
288338
</AccordionGroup>
289339

extensions/openrouter/index.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,16 @@ describe("openrouter provider hooks", () => {
202202

203203
it("injects provider routing into compat before applying stream wrappers", async () => {
204204
const provider = await registerSingleProviderPlugin(openrouterPlugin);
205+
let capturedPayload: Record<string, unknown> | undefined;
205206
const baseStreamFn = vi.fn(
206-
(..._args: Parameters<import("@earendil-works/pi-agent-core").StreamFn>) =>
207-
({ async *[Symbol.asyncIterator]() {} }) as never,
207+
(
208+
...args: Parameters<import("@earendil-works/pi-agent-core").StreamFn>
209+
): ReturnType<import("@earendil-works/pi-agent-core").StreamFn> => {
210+
const payload: Record<string, unknown> = {};
211+
void args[2]?.onPayload?.(payload, args[0]);
212+
capturedPayload = payload;
213+
return { async *[Symbol.asyncIterator]() {} } as never;
214+
},
208215
);
209216

210217
const wrapped = provider.wrapStreamFn?.({
@@ -235,6 +242,60 @@ describe("openrouter provider hooks", () => {
235242
const firstModel = firstCall?.[0];
236243
const compat = (firstModel as { compat?: { openRouterRouting?: { order?: unknown } } }).compat;
237244
expect(compat?.openRouterRouting?.order).toEqual(["moonshot"]);
245+
expect(capturedPayload?.provider).toEqual({
246+
order: ["moonshot"],
247+
});
248+
});
249+
250+
it("merges resolved OpenRouter model params into transport params", async () => {
251+
const provider = await registerSingleProviderPlugin(openrouterPlugin);
252+
const patch = provider.extraParamsForTransport?.({
253+
config: {
254+
models: {
255+
providers: {
256+
openrouter: {
257+
params: {
258+
provider: {
259+
sort: "price",
260+
data_collection: "deny",
261+
},
262+
},
263+
},
264+
},
265+
},
266+
},
267+
provider: "openrouter",
268+
modelId: "openai/gpt-5.4",
269+
extraParams: {
270+
provider: {
271+
sort: "latency",
272+
require_parameters: true,
273+
},
274+
temperature: 0.2,
275+
},
276+
model: {
277+
provider: "openrouter",
278+
api: "openai-completions",
279+
id: "openai/gpt-5.4",
280+
params: {
281+
responseCache: true,
282+
provider: {
283+
order: ["openai"],
284+
constructor: "ignored",
285+
},
286+
},
287+
},
288+
transport: "sse",
289+
} as never)?.patch;
290+
291+
expect(patch?.responseCache).toBe(true);
292+
expect(patch?.temperature).toBe(0.2);
293+
expect(patch?.provider).toEqual({
294+
sort: "latency",
295+
data_collection: "deny",
296+
order: ["openai"],
297+
require_parameters: true,
298+
});
238299
});
239300

240301
it("does not inject OpenRouter reasoning for Hunter Alpha", async () => {

extensions/openrouter/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
normalizeOpenRouterBaseUrl,
2323
OPENROUTER_BASE_URL,
2424
} from "./provider-catalog.js";
25+
import { resolveOpenRouterExtraParamsForTransport } from "./provider-routing.js";
2526
import { buildOpenRouterSpeechProvider } from "./speech-provider.js";
2627
import { wrapOpenRouterProviderStream } from "./stream.js";
2728
import {
@@ -165,6 +166,7 @@ export default definePluginEntry({
165166
supportsXHighThinking: ({ modelId }) => supportsOpenRouterXHighThinking(modelId),
166167
resolveThinkingProfile: ({ modelId }) => resolveOpenRouterThinkingProfile(modelId),
167168
isModernModelRef: () => true,
169+
extraParamsForTransport: resolveOpenRouterExtraParamsForTransport,
168170
wrapStreamFn: wrapOpenRouterProviderStream,
169171
isCacheTtlEligible: (ctx) => isOpenRouterCacheTtlModel(ctx.modelId),
170172
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
type OpenRouterExtraParamsContext = {
2+
config?: {
3+
models?: {
4+
providers?: Record<
5+
string,
6+
{
7+
params?: Record<string, unknown>;
8+
}
9+
>;
10+
};
11+
};
12+
extraParams: Record<string, unknown>;
13+
provider: string;
14+
model?: {
15+
params?: Record<string, unknown>;
16+
};
17+
};
18+
19+
const BLOCKED_RECORD_KEYS = new Set(["__proto__", "prototype", "constructor"]);
20+
21+
function sanitizeJsonLikeValue(value: unknown): unknown {
22+
if (value === undefined) {
23+
return undefined;
24+
}
25+
if (Array.isArray(value)) {
26+
return value.map(sanitizeJsonLikeValue).filter((entry) => entry !== undefined);
27+
}
28+
if (!value || typeof value !== "object") {
29+
return value;
30+
}
31+
return sanitizeRecord(value as Record<string, unknown>);
32+
}
33+
34+
function sanitizeRecord(value: Record<string, unknown>): Record<string, unknown> {
35+
return Object.fromEntries(
36+
Object.entries(value)
37+
.filter(([key, entry]) => !BLOCKED_RECORD_KEYS.has(key) && entry !== undefined)
38+
.map(([key, entry]) => [key, sanitizeJsonLikeValue(entry)]),
39+
);
40+
}
41+
42+
function readRecord(value: unknown): Record<string, unknown> | undefined {
43+
if (!value || typeof value !== "object" || Array.isArray(value)) {
44+
return undefined;
45+
}
46+
const sanitized = sanitizeRecord(value as Record<string, unknown>);
47+
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
48+
}
49+
50+
function mergeOpenRouterProviderRouting(params: {
51+
providerParams?: Record<string, unknown>;
52+
modelParams?: Record<string, unknown>;
53+
extraParams: Record<string, unknown>;
54+
}): Record<string, unknown> | undefined {
55+
const providerRouting = readRecord(params.providerParams?.provider);
56+
const modelRouting = readRecord(params.modelParams?.provider);
57+
const extraRouting = readRecord(params.extraParams.provider);
58+
const merged = {
59+
...(providerRouting ?? {}),
60+
...(modelRouting ?? {}),
61+
...(extraRouting ?? {}),
62+
};
63+
return Object.keys(merged).length > 0 ? merged : undefined;
64+
}
65+
66+
export function resolveOpenRouterExtraParamsForTransport(
67+
ctx: OpenRouterExtraParamsContext,
68+
): { patch?: Record<string, unknown> } | undefined {
69+
const providerConfigParams = readRecord(ctx.config?.models?.providers?.[ctx.provider]?.params);
70+
const modelParams = readRecord(ctx.model?.params);
71+
const providerRouting = mergeOpenRouterProviderRouting({
72+
providerParams: providerConfigParams,
73+
modelParams,
74+
extraParams: ctx.extraParams,
75+
});
76+
if (!providerConfigParams && !modelParams && !providerRouting) {
77+
return undefined;
78+
}
79+
return {
80+
patch: {
81+
...(providerConfigParams ?? {}),
82+
...(modelParams ?? {}),
83+
...ctx.extraParams,
84+
...(providerRouting ? { provider: providerRouting } : {}),
85+
},
86+
};
87+
}

extensions/openrouter/stream.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters<StreamFn>[0]):
5656
);
5757
}
5858

59+
function shouldPatchOpenRouterRoutingPayload(model: Parameters<StreamFn>[0]): boolean {
60+
const api = readString(model.api);
61+
return (api === undefined || api === "openai-completions") && isVerifiedOpenRouterRoute(model);
62+
}
63+
5964
function assistantMessageHasOpenAIToolCalls(message: Record<string, unknown>): boolean {
6065
return Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
6166
}
@@ -145,7 +150,7 @@ function injectOpenRouterRouting(
145150
if (!providerRouting) {
146151
return baseStreamFn;
147152
}
148-
return (model, context, options) =>
153+
const routedStreamFn: StreamFn = (model, context, options) =>
149154
(
150155
baseStreamFn ??
151156
((nextModel) => {
@@ -161,6 +166,17 @@ function injectOpenRouterRouting(
161166
context,
162167
options,
163168
);
169+
return createPayloadPatchStreamWrapper(
170+
routedStreamFn,
171+
({ payload }) => {
172+
if (payload.provider === undefined) {
173+
payload.provider = providerRouting;
174+
}
175+
},
176+
{
177+
shouldPatch: ({ model }) => shouldPatchOpenRouterRoutingPayload(model),
178+
},
179+
);
164180
}
165181

166182
function createOpenRouterAnthropicPrefillWrapper(baseStreamFn: StreamFn | undefined): StreamFn {

0 commit comments

Comments
 (0)