diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 16acd0da509..cfef94a2066 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -9,7 +9,11 @@ import { import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { createGoogleThinkingPayloadWrapper } from "openclaw/plugin-sdk/provider-stream"; -import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./api.js"; +import { + GOOGLE_GEMINI_DEFAULT_MODEL, + applyGoogleGeminiModelDefault, + normalizeGoogleModelId, +} from "./api.js"; import { buildGoogleGeminiCliBackend } from "./cli-backend.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; @@ -131,6 +135,7 @@ function createLazyGoogleGeminiCliProvider(): ProviderPlugin { methodId: "oauth", }, }, + normalizeModelId: ({ modelId }) => normalizeGoogleModelId(modelId), resolveDynamicModel: (ctx) => resolveGoogle31ForwardCompatModel({ providerId: GOOGLE_GEMINI_CLI_PROVIDER_ID, ctx }), isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), @@ -204,6 +209,7 @@ export default definePluginEntry({ id: "google", label: "Google AI Studio", docsPath: "/providers/models", + aliases: ["google-vertex"], envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], auth: [ createProviderApiKeyAuthMethod({ @@ -227,6 +233,7 @@ export default definePluginEntry({ }, }), ], + normalizeModelId: ({ modelId }) => normalizeGoogleModelId(modelId), resolveDynamicModel: (ctx) => resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }), wrapStreamFn: (ctx) => createGoogleThinkingPayloadWrapper(ctx.streamFn, ctx.thinkingLevel), diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 05983b88baf..dabf35704e8 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,6 +1,6 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { createToolStreamWrapper } from "openclaw/plugin-sdk/provider-stream"; -import { applyXaiModelCompat, buildXaiProvider } from "./api.js"; +import { applyXaiModelCompat, buildXaiProvider, normalizeXaiModelId } from "./api.js"; import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { @@ -57,6 +57,7 @@ export default defineSingleProviderPluginEntry({ return createToolStreamWrapper(streamFn, ctx.extraParams?.tool_stream !== false); }, normalizeResolvedModel: ({ model }) => applyXaiModelCompat(model), + normalizeModelId: ({ modelId }) => normalizeXaiModelId(modelId), resolveDynamicModel: (ctx) => resolveXaiForwardCompatModel({ providerId: PROVIDER_ID, ctx }), isModernModelRef: ({ modelId }) => isModernXaiModel(modelId), }, diff --git a/src/agents/model-selection.plugin-runtime.test.ts b/src/agents/model-selection.plugin-runtime.test.ts new file mode 100644 index 00000000000..ba62b712261 --- /dev/null +++ b/src/agents/model-selection.plugin-runtime.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const normalizeProviderModelIdWithPluginMock = vi.fn(); + +vi.mock("../plugins/provider-runtime.js", () => ({ + normalizeProviderModelIdWithPlugin: (params: unknown) => + normalizeProviderModelIdWithPluginMock(params), +})); + +describe("model-selection plugin runtime normalization", () => { + beforeEach(() => { + vi.resetModules(); + normalizeProviderModelIdWithPluginMock.mockReset(); + }); + + it("delegates provider-owned model id normalization to plugin runtime hooks", async () => { + normalizeProviderModelIdWithPluginMock.mockImplementation(({ provider, context }) => { + if ( + provider === "xai" && + (context as { modelId?: string }).modelId === "grok-4.20-experimental-beta-0304-reasoning" + ) { + return "grok-4.20-beta-latest-reasoning"; + } + return undefined; + }); + + const { parseModelRef } = await import("./model-selection.js"); + + expect(parseModelRef("grok-4.20-experimental-beta-0304-reasoning", "xai")).toEqual({ + provider: "xai", + model: "grok-4.20-beta-latest-reasoning", + }); + expect(normalizeProviderModelIdWithPluginMock).toHaveBeenCalledWith({ + provider: "xai", + context: { + provider: "xai", + modelId: "grok-4.20-experimental-beta-0304-reasoning", + }, + }); + }); +}); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 758ce74d1bb..42689f78dbc 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -6,9 +6,8 @@ import { toAgentModelListLike, } from "../config/model-input.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeGoogleModelId } from "../plugin-sdk/google.js"; -import { normalizeXaiModelId } from "../plugin-sdk/xai.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; +import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveAgentConfig, @@ -125,12 +124,6 @@ function normalizeProviderModelId(provider: string, model: string): string { return `anthropic/${normalizedAnthropicModel}`; } } - if (provider === "google" || provider === "google-vertex") { - return normalizeGoogleModelId(model); - } - if (provider === "xai") { - return normalizeXaiModelId(model); - } // OpenRouter-native models (e.g. "openrouter/aurora-alpha") need the full // "openrouter/" as the model ID sent to the API. Models from external // providers already contain a slash (e.g. "anthropic/claude-sonnet-4-5") and @@ -138,7 +131,15 @@ function normalizeProviderModelId(provider: string, model: string): string { if (provider === "openrouter" && !model.includes("/")) { return `openrouter/${model}`; } - return model; + return ( + normalizeProviderModelIdWithPlugin({ + provider, + context: { + provider, + modelId: model, + }, + }) ?? model + ); } export function normalizeModelRef(provider: string, model: string): ModelRef { diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 1ff0cd8c100..e7fb3e8ecfe 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -11,5 +11,3 @@ export type { export { applyNativeStreamingUsageCompat } from "./models-config.providers.policy.js"; export { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js"; export { resolveOllamaApiBase } from "../plugin-sdk/ollama-surface.js"; -export { normalizeGoogleModelId } from "../plugin-sdk/google.js"; -export { normalizeXaiModelId } from "../plugin-sdk/xai.js"; diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index eb3f63b3000..0561428ea66 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -9,8 +9,7 @@ import { } from "../agents/model-selection.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { normalizeGoogleModelId } from "../plugin-sdk/google.js"; -import { normalizeXaiModelId } from "../plugin-sdk/xai.js"; +import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; export type CachedModelPricing = { input: number; @@ -151,12 +150,14 @@ function canonicalizeOpenRouterLookupId(id: string): string { .replace(/^claude-(\d+)\.(\d+)-/u, "claude-$1-$2-") .replace(/^claude-([a-z]+)-(\d+)\.(\d+)$/u, "claude-$1-$2-$3"); } - if (provider === "google") { - model = normalizeGoogleModelId(model); - } - if (provider === "x-ai") { - model = normalizeXaiModelId(model); - } + model = + normalizeProviderModelIdWithPlugin({ + provider, + context: { + provider, + modelId: model, + }, + }) ?? model; return `${provider}/${model}`; } diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index ee100b0784d..866a5f796fd 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -30,6 +30,7 @@ import type { ProviderDiscoveryContext, ProviderFetchUsageSnapshotContext, ProviderModernModelPolicyContext, + ProviderNormalizeModelIdContext, ProviderNormalizeResolvedModelContext, ProviderPrepareDynamicModelContext, ProviderPrepareExtraParamsContext, @@ -65,6 +66,7 @@ export type { ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, ProviderModernModelPolicyContext, + ProviderNormalizeModelIdContext, ProviderPreparedRuntimeAuth, ProviderResolvedUsageAuth, ProviderPrepareExtraParamsContext, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 8c0067e01ec..17b4a2b5049 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -25,6 +25,7 @@ let buildProviderAuthDoctorHintWithPlugin: typeof import("./provider-runtime.js" let buildProviderMissingAuthMessageWithPlugin: typeof import("./provider-runtime.js").buildProviderMissingAuthMessageWithPlugin; let buildProviderUnknownModelHintWithPlugin: typeof import("./provider-runtime.js").buildProviderUnknownModelHintWithPlugin; let formatProviderAuthProfileApiKeyWithPlugin: typeof import("./provider-runtime.js").formatProviderAuthProfileApiKeyWithPlugin; +let normalizeProviderModelIdWithPlugin: typeof import("./provider-runtime.js").normalizeProviderModelIdWithPlugin; let prepareProviderExtraParams: typeof import("./provider-runtime.js").prepareProviderExtraParams; let resolveProviderStreamFn: typeof import("./provider-runtime.js").resolveProviderStreamFn; let resolveProviderCacheTtlEligibility: typeof import("./provider-runtime.js").resolveProviderCacheTtlEligibility; @@ -192,6 +193,7 @@ describe("provider-runtime", () => { buildProviderMissingAuthMessageWithPlugin, buildProviderUnknownModelHintWithPlugin, formatProviderAuthProfileApiKeyWithPlugin, + normalizeProviderModelIdWithPlugin, prepareProviderExtraParams, resolveProviderStreamFn, resolveProviderCacheTtlEligibility, @@ -247,6 +249,34 @@ describe("provider-runtime", () => { }); }); + it("can normalize model ids through provider aliases without changing ownership", () => { + resolvePluginProvidersMock.mockReturnValue([ + { + id: "google", + label: "Google", + aliases: ["google-vertex"], + auth: [], + normalizeModelId: ({ modelId }) => modelId.replace("flash-lite", "flash-lite-preview"), + }, + ]); + + expect( + normalizeProviderModelIdWithPlugin({ + provider: "google-vertex", + context: { + provider: "google-vertex", + modelId: "gemini-3.1-flash-lite", + }, + }), + ).toBe("gemini-3.1-flash-lite-preview"); + expect(resolveOwningPluginIdsForProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "google-vertex", + }), + ); + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); + }); + it("invalidates cached runtime providers when config mutates in place", () => { const config = { plugins: { @@ -342,6 +372,7 @@ describe("provider-runtime", () => { id: DEMO_PROVIDER_ID, label: "Demo", auth: [], + normalizeModelId: ({ modelId }) => modelId.replace("-legacy", ""), resolveDynamicModel: () => MODEL, prepareDynamicModel, capabilities: { @@ -395,6 +426,16 @@ describe("provider-runtime", () => { }), ).toMatchObject(MODEL); + expect( + normalizeProviderModelIdWithPlugin({ + provider: DEMO_PROVIDER_ID, + context: { + provider: DEMO_PROVIDER_ID, + modelId: "demo-model-legacy", + }, + }), + ).toBe("demo-model"); + await prepareProviderDynamicModel({ provider: DEMO_PROVIDER_ID, context: createDemoRuntimeContext({ diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 23dec34954f..90bd54bb1e6 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -19,6 +19,7 @@ import type { ProviderCreateStreamFnContext, ProviderDefaultThinkingPolicyContext, ProviderFetchUsageSnapshotContext, + ProviderNormalizeModelIdContext, ProviderModernModelPolicyContext, ProviderPrepareExtraParamsContext, ProviderPrepareDynamicModelContext, @@ -217,6 +218,25 @@ export function normalizeProviderResolvedModelWithPlugin(params: { ); } +export function normalizeProviderModelIdWithPlugin(params: { + provider: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + context: ProviderNormalizeModelIdContext; +}): string | undefined { + const plugin = + resolveProviderRuntimePlugin(params) ?? + resolveProviderPluginsForHooks({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }).find((candidate) => matchesProviderId(candidate, params.provider)); + const normalized = plugin?.normalizeModelId?.(params.context); + const trimmed = normalized?.trim(); + return trimmed ? trimmed : undefined; +} + export function resolveProviderCapabilitiesWithPlugin(params: { provider: string; config?: OpenClawConfig; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f130b07eba9..77b506244ef 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -363,6 +363,17 @@ export type ProviderNormalizeResolvedModelContext = { model: ProviderRuntimeModel; }; +/** + * Provider-owned model-id normalization before config/runtime lookup. + * + * Use this for provider-specific alias cleanup that should stay with the + * plugin rather than in core string tables. + */ +export type ProviderNormalizeModelIdContext = { + provider: string; + modelId: string; +}; + /** * Runtime auth input for providers that need an extra exchange step before * inference. The incoming `apiKey` is the raw credential resolved from auth @@ -829,6 +840,13 @@ export type ProviderPlugin = { normalizeResolvedModel?: ( ctx: ProviderNormalizeResolvedModelContext, ) => ProviderRuntimeModel | null | undefined; + /** + * Provider-owned model-id normalization. + * + * Runs before model lookup/canonicalization. Use this for alias cleanup such + * as provider-owned preview/legacy model ids. + */ + normalizeModelId?: (ctx: ProviderNormalizeModelIdContext) => string | null | undefined; /** * Static provider capability overrides consumed by shared transcript/tooling * logic.