refactor(plugins): register provider model id hooks

This commit is contained in:
Peter Steinberger
2026-03-28 05:41:40 +00:00
parent 49f693d06a
commit c7883fe892
10 changed files with 151 additions and 21 deletions

View File

@@ -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),

View File

@@ -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),
},

View File

@@ -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",
},
});
});
});

View File

@@ -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/<name>" 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 {

View File

@@ -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";

View File

@@ -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}`;
}

View File

@@ -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,

View File

@@ -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({

View File

@@ -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;

View File

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