diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b8f499eef6..e7874fd0b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Nodes/CLI: add `openclaw nodes remove --node ` and `node.pair.remove` so stale gateway-owned node pairing records can be cleaned without hand-editing state files. Thanks @openclaw. - Docker: install the CA certificate bundle in the slim runtime image so HTTPS calls from containerized gateways no longer fail TLS setup after the `bookworm-slim` base switch. Fixes #72787. Thanks @ryuhaneul. +- Providers/OpenRouter: remove retired Hunter Alpha and Healer Alpha static catalog rows and disable proxy reasoning injection for stale Hunter Alpha configs, so replies are not hidden when OpenRouter returns answer text in reasoning fields. Fixes #43942. Thanks @EvanDataForge. - Providers/reasoning: let Groq and LM Studio declare provider-native reasoning effort values, so Qwen thinking models receive `none`/`default` or `off`/`on` instead of OpenAI-only `low`/`medium` values. Fixes #32638. Thanks @Aqu1bp, @mgoulart, @Norpps, and @BSTail. - Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe. - Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354. diff --git a/docs/providers/openrouter.md b/docs/providers/openrouter.md index 8725c62b0f6..da030f663a0 100644 --- a/docs/providers/openrouter.md +++ b/docs/providers/openrouter.md @@ -53,12 +53,10 @@ available providers and models, see [/concepts/model-providers](/concepts/model- Bundled fallback examples: -| Model ref | Notes | -| ------------------------------------ | ----------------------------- | -| `openrouter/auto` | OpenRouter automatic routing | -| `openrouter/moonshotai/kimi-k2.6` | Kimi K2.6 via MoonshotAI | -| `openrouter/openrouter/healer-alpha` | OpenRouter Healer Alpha route | -| `openrouter/openrouter/hunter-alpha` | OpenRouter Hunter Alpha route | +| Model ref | Notes | +| --------------------------------- | ---------------------------- | +| `openrouter/auto` | OpenRouter automatic routing | +| `openrouter/moonshotai/kimi-k2.6` | Kimi K2.6 via MoonshotAI | ## Image generation @@ -136,7 +134,9 @@ does **not** inject those OpenRouter-specific headers or Anthropic cache markers On supported non-`auto` routes, OpenClaw maps the selected thinking level to OpenRouter proxy reasoning payloads. Unsupported model hints and - `openrouter/auto` skip that reasoning injection. + `openrouter/auto` skip that reasoning injection. Hunter Alpha also skips + proxy reasoning for stale configured model refs because OpenRouter could + return final answer text in reasoning fields for that retired route. diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index f733afc3f73..a398f3f53ed 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -28,6 +28,7 @@ title: "Thinking levels" - Anthropic Claude Opus 4.7 also exposes `/think max`; it maps to the same provider-owned max effort path. - Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings. - OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value. + - Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields. - Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family. - MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format. - Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`). diff --git a/extensions/openrouter/api.ts b/extensions/openrouter/api.ts index bf455d4a64a..cf2b950b176 100644 --- a/extensions/openrouter/api.ts +++ b/extensions/openrouter/api.ts @@ -1,5 +1,8 @@ export { buildOpenRouterImageGenerationProvider } from "./image-generation-provider.js"; -export { buildOpenrouterProvider } from "./provider-catalog.js"; +export { + buildOpenrouterProvider, + isOpenRouterProxyReasoningUnsupportedModel, +} from "./provider-catalog.js"; export { buildOpenRouterSpeechProvider } from "./speech-provider.js"; export { applyOpenrouterConfig, diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index f1d84387a3d..d13412a4840 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -3,7 +3,10 @@ import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin- import { registerProviderPlugin } from "../../test/helpers/plugins/provider-registration.js"; import { expectPassthroughReplayPolicy } from "../../test/helpers/provider-replay-policy.ts"; import openrouterPlugin from "./index.js"; -import { buildOpenrouterProvider } from "./provider-catalog.js"; +import { + buildOpenrouterProvider, + isOpenRouterProxyReasoningUnsupportedModel, +} from "./provider-catalog.js"; describe("openrouter provider hooks", () => { it("registers OpenRouter speech alongside model and media providers", async () => { @@ -26,6 +29,18 @@ describe("openrouter provider hooks", () => { ); }); + it("does not include retired stealth models in the bundled catalog", () => { + expect(buildOpenrouterProvider().models?.map((model) => model.id)).not.toEqual( + expect.arrayContaining(["openrouter/hunter-alpha", "openrouter/healer-alpha"]), + ); + }); + + it("keeps stale Hunter Alpha configs out of OpenRouter proxy reasoning", () => { + expect(isOpenRouterProxyReasoningUnsupportedModel("openrouter/hunter-alpha")).toBe(true); + expect(isOpenRouterProxyReasoningUnsupportedModel("openrouter/hunter-alpha:free")).toBe(true); + expect(isOpenRouterProxyReasoningUnsupportedModel("openrouter/healer-alpha")).toBe(false); + }); + it("owns passthrough-gemini replay policy for Gemini-backed models", async () => { await expectPassthroughReplayPolicy({ plugin: openrouterPlugin, @@ -88,6 +103,26 @@ describe("openrouter provider hooks", () => { baseUrl: "https://openrouter.ai/api/v1", }); + expect( + provider.normalizeResolvedModel?.({ + provider: "openrouter", + model: { + provider: "openrouter", + id: "openrouter/hunter-alpha", + name: "Hunter Alpha", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_048_576, + maxTokens: 65_536, + }, + } as never), + ).toMatchObject({ + reasoning: false, + }); + expect( provider.normalizeTransport?.({ provider: "openrouter", @@ -141,4 +176,43 @@ describe("openrouter provider hooks", () => { }, }); }); + + it("does not inject OpenRouter reasoning for Hunter Alpha", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + let capturedPayload: Record | undefined; + const baseStreamFn = vi.fn( + ( + ...args: Parameters + ): ReturnType => { + void args[2]?.onPayload?.({}, args[0]); + return { async *[Symbol.asyncIterator]() {} } as never; + }, + ); + + const wrapped = provider.wrapStreamFn?.({ + provider: "openrouter", + modelId: "openrouter/hunter-alpha", + streamFn: baseStreamFn, + thinkingLevel: "high", + } as never); + + void wrapped?.( + { + provider: "openrouter", + api: "openai-completions", + id: "openrouter/hunter-alpha", + compat: {}, + } as never, + { messages: [] } as never, + { + onPayload: (payload: unknown) => { + capturedPayload = payload as Record; + return payload; + }, + } as never, + ); + + expect(capturedPayload).toEqual({}); + expect(baseStreamFn).toHaveBeenCalledOnce(); + }); }); diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 02cf1da3b80..717e5dcf6de 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -17,6 +17,7 @@ import { openrouterMediaUnderstandingProvider } from "./media-understanding-prov import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildOpenrouterProvider, + isOpenRouterProxyReasoningUnsupportedModel, normalizeOpenRouterBaseUrl, OPENROUTER_BASE_URL, } from "./provider-catalog.js"; @@ -35,12 +36,17 @@ const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [ function normalizeOpenRouterResolvedModel(model: T): T | undefined { const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl); - if (!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) { + const reasoning = isOpenRouterProxyReasoningUnsupportedModel(model.id) ? false : model.reasoning; + if ( + (!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) && + reasoning === model.reasoning + ) { return undefined; } return { ...model, - baseUrl: normalizedBaseUrl, + ...(normalizedBaseUrl ? { baseUrl: normalizedBaseUrl } : {}), + reasoning, }; } @@ -59,7 +65,9 @@ export default definePluginEntry({ api: "openai-completions", provider: PROVIDER_ID, baseUrl: OPENROUTER_BASE_URL, - reasoning: capabilities?.reasoning ?? false, + reasoning: + (capabilities?.reasoning ?? false) && + !isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId), input: capabilities?.input ?? ["text"], cost: capabilities?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 087174bbc63..f7e1137b267 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -11,6 +11,7 @@ const OPENROUTER_DEFAULT_COST = { cacheRead: 0, cacheWrite: 0, }; +const OPENROUTER_PROXY_REASONING_UNSUPPORTED_MODEL_IDS = new Set(["openrouter/hunter-alpha"]); const OPENROUTER_KIMI_K2_6_COST = { input: 0.8, output: 3.5, @@ -33,6 +34,17 @@ export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string return undefined; } +export function isOpenRouterProxyReasoningUnsupportedModel(modelId: string | undefined): boolean { + const normalized = (modelId ?? "").trim().toLowerCase(); + if (!normalized) { + return false; + } + return ( + OPENROUTER_PROXY_REASONING_UNSUPPORTED_MODEL_IDS.has(normalized) || + normalized.startsWith("openrouter/hunter-alpha:") + ); +} + export function buildOpenrouterProvider(): ModelProviderConfig { return { baseUrl: OPENROUTER_BASE_URL, @@ -47,24 +59,6 @@ export function buildOpenrouterProvider(): ModelProviderConfig { contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW, maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS, }, - { - id: "openrouter/hunter-alpha", - name: "Hunter Alpha", - reasoning: true, - input: ["text"], - cost: OPENROUTER_DEFAULT_COST, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "openrouter/healer-alpha", - name: "Healer Alpha", - reasoning: true, - input: ["text", "image"], - cost: OPENROUTER_DEFAULT_COST, - contextWindow: 262144, - maxTokens: 65536, - }, { id: "moonshotai/kimi-k2.6", name: "MoonshotAI: Kimi K2.6", diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 88303c238c3..37573c455cb 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -1,6 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; import { OPENROUTER_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family"; +import { isOpenRouterProxyReasoningUnsupportedModel } from "./provider-catalog.js"; function injectOpenRouterRouting( baseStreamFn: StreamFn | undefined, @@ -45,6 +46,9 @@ export function wrapOpenRouterProviderStream( wrapStreamFn({ ...ctx, streamFn: routedStreamFn, + thinkingLevel: isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId) + ? undefined + : ctx.thinkingLevel, }) ?? undefined ); } diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 6776d56a3b1..bc25eaf614e 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -852,6 +852,34 @@ describe("openai transport stream", () => { }); }); + it("does not build OpenRouter reasoning params for Hunter Alpha when reasoning is disabled", () => { + const params = buildOpenAICompletionsParams( + { + id: "openrouter/hunter-alpha", + name: "Hunter 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: 1_048_576, + maxTokens: 65_536, + } satisfies Model<"openai-completions">, + { + systemPrompt: "system", + messages: [], + tools: [], + } as never, + { + reasoningEffort: "high", + } as never, + ) as { reasoning?: unknown; reasoning_effort?: unknown }; + + expect(params).not.toHaveProperty("reasoning"); + expect(params).not.toHaveProperty("reasoning_effort"); + }); + it("uses system role instead of developer for responses providers that disable developer role", () => { const params = buildOpenAIResponsesParams( {