diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a2fdbd9bd..66fc6672260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,7 @@ Docs: https://docs.openclaw.ai - Slack/streaming: resolve native streaming recipient teams from the inbound user when available, with a monitor-team fallback, so DM and shared-workspace streams target the right recipient more reliably. - OpenRouter/streaming: treat `reasoning_details.response.output_text` and `reasoning_details.response.text` as visible assistant output on OpenRouter-compatible completions streams, while keeping `reasoning.text` hidden and refusing to surface ambiguous bare `text` items by default so visible replies, thinking blocks, and tool calls can coexist in the same chunk. (#67410) Thanks @neeravmakwana. - Models/OpenRouter aliases: resolve `openrouter:auto` to the canonical `openrouter/auto` model and map `openrouter:free` to the first configured concrete `openrouter/...:free` model instead of mis-resolving these compatibility aliases under the default provider. (#57066) Thanks @sumiisiaran. +- OpenRouter/Arcee: canonicalize stale OpenRouter `https://openrouter.ai/v1` base URLs during provider config normalization and runtime model/transport resolution, so fresh `models.json` writes and previously discovered rows self-heal back to `https://openrouter.ai/api/v1` instead of breaking OpenRouter-routed requests. (#67295) Thanks @achalkov. ## 2026.4.14 diff --git a/extensions/arcee/index.test.ts b/extensions/arcee/index.test.ts index c97b6fd05f0..b2aac6ad601 100644 --- a/extensions/arcee/index.test.ts +++ b/extensions/arcee/index.test.ts @@ -164,4 +164,48 @@ describe("arcee provider plugin", () => { } as never), ).toBeUndefined(); }); + + it("canonicalizes stale OpenRouter /v1 config and transport metadata", async () => { + const provider = await registerSingleProviderPlugin(arceePlugin); + + expect( + provider.normalizeConfig?.({ + provider: "arcee", + providerConfig: { + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1/", + models: [], + }, + } as never), + ).toMatchObject({ + baseUrl: "https://openrouter.ai/api/v1", + }); + + expect( + provider.normalizeResolvedModel?.({ + modelId: "arcee/trinity-large-thinking", + model: { + provider: "arcee", + id: "trinity-large-thinking", + name: "Trinity Large Thinking", + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1", + }, + } as never), + ).toMatchObject({ + id: "arcee/trinity-large-thinking", + baseUrl: "https://openrouter.ai/api/v1", + }); + + expect( + provider.normalizeTransport?.({ + provider: "arcee", + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1", + } as never), + ).toEqual({ + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }); + }); }); diff --git a/extensions/arcee/index.ts b/extensions/arcee/index.ts index 5f15490f0e9..e7fd0126950 100644 --- a/extensions/arcee/index.ts +++ b/extensions/arcee/index.ts @@ -5,7 +5,6 @@ import { type ProviderCatalogContext, } from "openclaw/plugin-sdk/provider-catalog-shared"; import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { applyArceeConfig, applyArceeOpenRouterConfig, @@ -15,7 +14,7 @@ import { import { buildArceeProvider, buildArceeOpenRouterProvider, - isArceeOpenRouterBaseUrl, + normalizeArceeOpenRouterBaseUrl, toArceeOpenRouterModelId, } from "./provider-catalog.js"; @@ -70,13 +69,6 @@ function buildArceeAuthMethods() { ]; } -function readConfiguredArceeCatalogEntries(config: OpenClawConfig | undefined) { - return readConfiguredProviderCatalogEntries({ - config, - providerId: PROVIDER_ID, - }); -} - async function resolveArceeCatalog(ctx: ProviderCatalogContext) { const directKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; if (directKey) { @@ -94,12 +86,18 @@ async function resolveArceeCatalog(ctx: ProviderCatalogContext) { function normalizeArceeResolvedModel( model: T, ): T | undefined { - if (!isArceeOpenRouterBaseUrl(model.baseUrl)) { + const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(model.baseUrl); + if (!normalizedBaseUrl) { + return undefined; + } + const normalizedId = toArceeOpenRouterModelId(model.id); + if (normalizedId === model.id && normalizedBaseUrl === model.baseUrl) { return undefined; } return { ...model, - id: toArceeOpenRouterModelId(model.id), + id: normalizedId, + baseUrl: normalizedBaseUrl, }; } @@ -117,8 +115,27 @@ export default definePluginEntry({ catalog: { run: resolveArceeCatalog, }, - augmentModelCatalog: ({ config }) => readConfiguredArceeCatalogEntries(config), + augmentModelCatalog: ({ config }) => + readConfiguredProviderCatalogEntries({ + config, + providerId: PROVIDER_ID, + }), + normalizeConfig: ({ providerConfig }) => { + const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(providerConfig.baseUrl); + return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl + ? { ...providerConfig, baseUrl: normalizedBaseUrl } + : undefined; + }, normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model), + normalizeTransport: ({ api, baseUrl }) => { + const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl); + return normalizedBaseUrl && normalizedBaseUrl !== baseUrl + ? { + api, + baseUrl: normalizedBaseUrl, + } + : undefined; + }, ...OPENAI_COMPATIBLE_REPLAY_HOOKS, }); }, diff --git a/extensions/arcee/provider-catalog.ts b/extensions/arcee/provider-catalog.ts index 4ea840446da..e8a95566a05 100644 --- a/extensions/arcee/provider-catalog.ts +++ b/extensions/arcee/provider-catalog.ts @@ -2,13 +2,25 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-sha import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./models.js"; export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1"; function normalizeBaseUrl(baseUrl: string | undefined): string { return (baseUrl ?? "").trim().replace(/\/+$/, ""); } +export function normalizeArceeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) { + return undefined; + } + if (normalized === OPENROUTER_BASE_URL || normalized === OPENROUTER_LEGACY_BASE_URL) { + return OPENROUTER_BASE_URL; + } + return undefined; +} + export function isArceeOpenRouterBaseUrl(baseUrl: string | undefined): boolean { - return normalizeBaseUrl(baseUrl) === OPENROUTER_BASE_URL; + return normalizeArceeOpenRouterBaseUrl(baseUrl) === OPENROUTER_BASE_URL; } export function toArceeOpenRouterModelId(modelId: string): string { diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 991c195e3c3..c8e52b513a9 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -30,6 +30,54 @@ describe("openrouter provider hooks", () => { ).toBe("native"); }); + it("canonicalizes stale OpenRouter /v1 config and runtime metadata", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + + expect( + provider.normalizeConfig?.({ + provider: "openrouter", + providerConfig: { + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1/", + models: [], + }, + } as never), + ).toMatchObject({ + baseUrl: "https://openrouter.ai/api/v1", + }); + + expect( + provider.normalizeResolvedModel?.({ + provider: "openrouter", + model: { + provider: "openrouter", + id: "openai/gpt-5.4", + name: "openai/gpt-5.4", + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + }, + } as never), + ).toMatchObject({ + baseUrl: "https://openrouter.ai/api/v1", + }); + + expect( + provider.normalizeTransport?.({ + provider: "openrouter", + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1", + } as never), + ).toEqual({ + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }); + }); + it("injects provider routing into compat before applying stream wrappers", async () => { const provider = await registerSingleProviderPlugin(openrouterPlugin); const baseStreamFn = vi.fn( diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 0f6dc7baf4b..c4066501e76 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -14,11 +14,14 @@ import { } from "openclaw/plugin-sdk/provider-stream-family"; import { openrouterMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; -import { buildOpenrouterProvider } from "./provider-catalog.js"; +import { + buildOpenrouterProvider, + normalizeOpenRouterBaseUrl, + OPENROUTER_BASE_URL, +} from "./provider-catalog.js"; import { wrapOpenRouterProviderStream } from "./stream.js"; const PROVIDER_ID = "openrouter"; -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [ "anthropic/", @@ -27,6 +30,17 @@ const OPENROUTER_CACHE_TTL_MODEL_PREFIXES = [ "zai/", ] as const; +function normalizeOpenRouterResolvedModel(model: T): T | undefined { + const normalizedBaseUrl = normalizeOpenRouterBaseUrl(model.baseUrl); + if (!normalizedBaseUrl || normalizedBaseUrl === model.baseUrl) { + return undefined; + } + return { + ...model, + baseUrl: normalizedBaseUrl, + }; +} + export default definePluginEntry({ id: "openrouter", name: "OpenRouter Provider", @@ -100,6 +114,22 @@ export default definePluginEntry({ prepareDynamicModel: async (ctx) => { await loadOpenRouterModelCapabilities(ctx.modelId); }, + normalizeConfig: ({ providerConfig }) => { + const normalizedBaseUrl = normalizeOpenRouterBaseUrl(providerConfig.baseUrl); + return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl + ? { ...providerConfig, baseUrl: normalizedBaseUrl } + : undefined; + }, + normalizeResolvedModel: ({ model }) => normalizeOpenRouterResolvedModel(model), + normalizeTransport: ({ api, baseUrl }) => { + const normalizedBaseUrl = normalizeOpenRouterBaseUrl(baseUrl); + return normalizedBaseUrl && normalizedBaseUrl !== baseUrl + ? { + api, + baseUrl: normalizedBaseUrl, + } + : undefined; + }, ...PASSTHROUGH_GEMINI_REPLAY_HOOKS, resolveReasoningOutputMode: () => "native", isModernModelRef: () => true, diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 0488f0a1366..f905f381b51 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -1,6 +1,7 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1"; const OPENROUTER_DEFAULT_MODEL_ID = "auto"; const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000; const OPENROUTER_DEFAULT_MAX_TOKENS = 8192; @@ -11,6 +12,21 @@ const OPENROUTER_DEFAULT_COST = { cacheWrite: 0, }; +function normalizeBaseUrl(baseUrl: string | undefined): string { + return (baseUrl ?? "").trim().replace(/\/+$/, ""); +} + +export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string | undefined { + const normalized = normalizeBaseUrl(baseUrl); + if (!normalized) { + return undefined; + } + if (normalized === OPENROUTER_BASE_URL || normalized === OPENROUTER_LEGACY_BASE_URL) { + return OPENROUTER_BASE_URL; + } + return undefined; +} + export function buildOpenrouterProvider(): ModelProviderConfig { return { baseUrl: OPENROUTER_BASE_URL, diff --git a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts index 1f5c20f7be0..ae44fef823e 100644 --- a/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts +++ b/src/agents/pi-embedded-runner/model.provider-runtime.test-support.ts @@ -5,6 +5,7 @@ const OPENAI_BASE_URL = "https://api.openai.com/v1"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const OPENAI_CODEX_LEGACY_BASE_URL = "https://chatgpt.com/backend-api/v1"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1"; const ANTHROPIC_BASE_URL = "https://api.anthropic.com"; const XAI_BASE_URL = "https://api.x.ai/v1"; const ZAI_BASE_URL = "https://api.z.ai/api/paas/v4"; @@ -69,7 +70,28 @@ function isNativeOpenAICodexBaseUrl(baseUrl?: string): boolean { return baseUrl === OPENAI_CODEX_BASE_URL || baseUrl === OPENAI_CODEX_LEGACY_BASE_URL; } +function normalizeOpenRouterBaseUrl(baseUrl?: string): string | undefined { + const normalized = typeof baseUrl === "string" ? baseUrl.trim().replace(/\/+$/, "") : ""; + if (!normalized) { + return undefined; + } + if (normalized === OPENROUTER_BASE_URL || normalized === OPENROUTER_LEGACY_BASE_URL) { + return OPENROUTER_BASE_URL; + } + return undefined; +} + function normalizeDynamicModel(params: { provider: string; model: ResolvedModelLike }) { + if (params.provider === "openrouter") { + const baseUrl = + typeof params.model.baseUrl === "string" + ? normalizeOpenRouterBaseUrl(params.model.baseUrl) + : undefined; + if (baseUrl && baseUrl !== params.model.baseUrl) { + return { ...params.model, baseUrl }; + } + return undefined; + } if (params.provider !== "openai-codex") { return undefined; } @@ -135,6 +157,13 @@ function normalizeTransport(params: { baseUrl: OPENAI_CODEX_BASE_URL, }; } + const normalizedOpenRouterBaseUrl = normalizeOpenRouterBaseUrl(params.context.baseUrl); + if (normalizedOpenRouterBaseUrl && normalizedOpenRouterBaseUrl !== params.context.baseUrl) { + return { + api: params.context.api, + baseUrl: normalizedOpenRouterBaseUrl, + }; + } return undefined; } diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 5eaa5049149..6c3472177bb 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1091,6 +1091,35 @@ describe("resolveModel", () => { }); }); + it("normalizes stale discovered openrouter /v1 metadata", () => { + mockDiscoveredModel(discoverModels, { + provider: "openrouter", + modelId: "openai/gpt-5.4", + templateModel: { + provider: "openrouter", + id: "openai/gpt-5.4", + name: "GPT-5.4", + api: "openai-completions", + baseUrl: "https://openrouter.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }, + }); + + const result = resolveModelForTest("openrouter", "openai/gpt-5.4", "/tmp/agent"); + + expect(result.error).toBeUndefined(); + expect(result.model).toMatchObject({ + provider: "openrouter", + id: "openai/gpt-5.4", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }); + }); + it("normalizes discovered openai-codex metadata when api is missing", () => { mockDiscoveredModel(discoverModels, { provider: "openai-codex",