diff --git a/extensions/vercel-ai-gateway/index.test.ts b/extensions/vercel-ai-gateway/index.test.ts new file mode 100644 index 000000000000..2b176e5eddbd --- /dev/null +++ b/extensions/vercel-ai-gateway/index.test.ts @@ -0,0 +1,22 @@ +// Vercel Ai Gateway tests cover provider runtime hooks. +import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; + +describe("vercel ai gateway provider hooks", () => { + it("resolves live-only model ids for the embedded runner", async () => { + const provider = await registerSingleProviderPlugin(plugin); + const model = provider.resolveDynamicModel?.({ + provider: "vercel-ai-gateway", + modelId: "custom/provider-model", + modelRegistry: { find: () => null }, + } as never); + + expect(model).toMatchObject({ + id: "custom/provider-model", + provider: "vercel-ai-gateway", + api: "anthropic-messages", + baseUrl: "https://ai-gateway.vercel.sh", + }); + }); +}); diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index c14ffc33a76b..215032950622 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -4,6 +4,7 @@ import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from import { buildStaticVercelAiGatewayProvider, buildVercelAiGatewayProvider, + resolveVercelAiGatewayModel, } from "./provider-catalog.js"; import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js"; @@ -37,6 +38,7 @@ export default defineSingleProviderPluginEntry({ buildProvider: buildVercelAiGatewayProvider, buildStaticProvider: buildStaticVercelAiGatewayProvider, }, + resolveDynamicModel: ({ modelId }) => resolveVercelAiGatewayModel(modelId), resolveThinkingProfile: ({ modelId }) => resolveVercelAiGatewayThinkingProfile(modelId), }, }); diff --git a/extensions/vercel-ai-gateway/models.ts b/extensions/vercel-ai-gateway/models.ts index b5e2f71070d5..31baa4eb31e9 100644 --- a/extensions/vercel-ai-gateway/models.ts +++ b/extensions/vercel-ai-gateway/models.ts @@ -142,6 +142,21 @@ function getStaticFallbackModel(id: string): ModelDefinitionConfig | undefined { return fallback ? buildStaticModelDefinition(fallback) : undefined; } +/** Builds runtime metadata for models returned by the live gateway catalog. */ +export function resolveVercelAiGatewayDynamicModel(modelId: string): ModelDefinitionConfig { + return ( + getStaticFallbackModel(modelId) ?? { + id: modelId, + name: modelId, + reasoning: false, + input: ["text"], + contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW, + maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS, + cost: VERCEL_AI_GATEWAY_DEFAULT_COST, + } + ); +} + export function getStaticVercelAiGatewayModelCatalog(): ModelDefinitionConfig[] { return STATIC_VERCEL_AI_GATEWAY_MODEL_CATALOG.map(buildStaticModelDefinition); } diff --git a/extensions/vercel-ai-gateway/provider-catalog.test.ts b/extensions/vercel-ai-gateway/provider-catalog.test.ts index e4dbcd6e8d69..e20db77fb23b 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.test.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.test.ts @@ -20,9 +20,11 @@ import { VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW, VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS, } from "./api.js"; +import { resolveVercelAiGatewayDynamicModel } from "./models.js"; import { buildStaticVercelAiGatewayProvider, buildVercelAiGatewayProvider, + resolveVercelAiGatewayModel, } from "./provider-catalog.js"; const STATIC_MODEL_IDS = [ @@ -83,6 +85,42 @@ describe("vercel ai gateway provider catalog", () => { }); }); + it("builds runtime metadata for live-only model ids", () => { + expect(resolveVercelAiGatewayDynamicModel("custom/provider-model")).toEqual({ + id: "custom/provider-model", + name: "custom/provider-model", + reasoning: false, + input: ["text"], + contextWindow: VERCEL_AI_GATEWAY_DEFAULT_CONTEXT_WINDOW, + maxTokens: VERCEL_AI_GATEWAY_DEFAULT_MAX_TOKENS, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + }); + }); + + it("adds transport metadata for runtime model resolution", () => { + expect(resolveVercelAiGatewayModel("custom/provider-model")).toMatchObject({ + id: "custom/provider-model", + provider: "vercel-ai-gateway", + api: "anthropic-messages", + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + }); + }); + + it("preserves provider thinking metadata for known live-only upstream models", () => { + expect(resolveVercelAiGatewayModel("openai/gpt-5.5")).toMatchObject({ + reasoning: true, + input: ["text", "image"], + }); + expect(resolveVercelAiGatewayModel("anthropic/claude-sonnet-4-6")).toMatchObject({ + input: ["text", "image"], + }); + }); + it("falls back to the static catalog for malformed successful model list payloads", async () => { for (const payload of [[], { data: {} }, { data: [null] }]) { clearLiveCatalogCacheForTests(); diff --git a/extensions/vercel-ai-gateway/provider-catalog.ts b/extensions/vercel-ai-gateway/provider-catalog.ts index 45a22a68077d..d7f6687c0464 100644 --- a/extensions/vercel-ai-gateway/provider-catalog.ts +++ b/extensions/vercel-ai-gateway/provider-catalog.ts @@ -3,8 +3,43 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-sha import { discoverVercelAiGatewayModels, getStaticVercelAiGatewayModelCatalog, + resolveVercelAiGatewayDynamicModel, VERCEL_AI_GATEWAY_BASE_URL, + VERCEL_AI_GATEWAY_PROVIDER_ID, } from "./models.js"; +import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js"; + +const VERCEL_AI_GATEWAY_IMAGE_MODEL_IDS = new Set([ + "openai/gpt-5.5", + "openai/gpt-5.5-pro", + "openai/gpt-5.4", + "openai/gpt-5.4-pro", + "openai/gpt-5.4-mini", + "openai/gpt-5.4-nano", + "openai/gpt-5.3-codex", + "openai/gpt-5.3-codex-spark", + "openai/gpt-5.2", + "openai/gpt-5.2-codex", + "openai/gpt-5.1-codex", +]); + +export function resolveVercelAiGatewayModel(modelId: string) { + const model = resolveVercelAiGatewayDynamicModel(modelId); + const input: Array<"text" | "image"> = model.input.includes("image") + ? ["text", "image"] + : VERCEL_AI_GATEWAY_IMAGE_MODEL_IDS.has(modelId) || + /^anthropic\/claude-(?:opus|sonnet|haiku)-/.test(modelId) + ? ["text", "image"] + : ["text"]; + return { + ...model, + reasoning: model.reasoning || Boolean(resolveVercelAiGatewayThinkingProfile(modelId)), + input, + api: "anthropic-messages" as const, + provider: VERCEL_AI_GATEWAY_PROVIDER_ID, + baseUrl: VERCEL_AI_GATEWAY_BASE_URL, + }; +} export function buildStaticVercelAiGatewayProvider(): ModelProviderConfig { return {