From caa01f4f5b0814802bc59c25596c4c870901d023 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 18:09:37 -0700 Subject: [PATCH] refactor(providers): table-driven forward compat and manifest-derived contract tests (#113940) * refactor(providers): share family forward compat resolution * test(providers): derive runtime contracts from manifests * test(providers): validate manifest fixture inputs * refactor(zai): narrow forward compat inputs --- docs/plugins/sdk-subpaths.md | 2 +- extensions/github-copilot/index.ts | 3 +- extensions/github-copilot/model-metadata.ts | 2 + .../provider-auth.contract.test.ts | 3 +- .../provider-runtime.contract.test.ts | 6 +- extensions/google/provider-models.ts | 196 ++++++++--------- extensions/openai/openai-provider.ts | 208 +++++++----------- .../openai/provider-runtime.contract.test.ts | 6 +- .../venice/provider-runtime.contract.test.ts | 6 +- extensions/zai/index.ts | 66 +++--- src/plugin-sdk/provider-model-shared.ts | 1 + .../test-helpers/provider-auth-contract.ts | 9 +- .../test-helpers/provider-runtime-contract.ts | 161 +++++++------- .../contracts/plugin-sdk-subpaths.test.ts | 7 +- src/plugins/provider-model-helpers.test.ts | 63 +++++- src/plugins/provider-model-helpers.ts | 116 ++++++++-- 16 files changed, 473 insertions(+), 382 deletions(-) diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index d2107b4a330..9955d4868c6 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -146,7 +146,7 @@ are private-local. | `plugin-sdk/provider-auth-result` | Private-local after July 2026; Standard OAuth auth-result builder | | `plugin-sdk/provider-env-vars` | Private-local after July 2026; Provider auth env-var lookup helpers | | `plugin-sdk/provider-auth` | `createProviderApiKeyAuthMethod`, `ensureApiKeyFromOptionEnvOrPrompt`, `upsertAuthProfile`, `upsertApiKeyProfile`, `writeOAuthCredentials`, OpenAI Codex auth-import helpers, deprecated `resolveOpenClawAgentDir` compatibility export | - | `plugin-sdk/provider-model-shared` | Private-local after July 2026; `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `selectPreferredLocalModelId`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and shared model-id normalization helpers | + | `plugin-sdk/provider-model-shared` | Private-local after July 2026; `ProviderReplayFamily`, `buildProviderReplayFamilyHooks`, `resolveFamilyForwardCompatModel`, `selectPreferredLocalModelId`, `normalizeModelCompat`, shared replay-policy builders, provider-endpoint helpers, and shared model-id normalization helpers | | `plugin-sdk/provider-catalog-live-runtime` | Private-local after July 2026; Live provider model catalog helpers for guarded `/models`-style discovery: `buildLiveModelProviderConfig`, provider-owned `projectRows`, `fetchLiveProviderModelRows`, `getCachedLiveProviderModelRows`, `fetchLiveProviderModelIds`, `LiveModelCatalogHttpError`, `clearLiveCatalogCacheForTests`, TTL cache, and static fallback | | `plugin-sdk/provider-catalog-runtime` | Provider catalog augmentation runtime hook and plugin-provider registry seams for contract tests | | `plugin-sdk/provider-catalog-shared` | Private-local after July 2026; `findCatalogTemplate`, `buildSingleProviderApiKeyCatalog`, `buildManifestModelProviderConfig`, `supportsNativeStreamingUsageCompat`, `applyProviderNativeStreamingUsageCompat` | diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 639fe282229..49b7cff243e 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -23,7 +23,7 @@ import { import { PUBLIC_GITHUB_COPILOT_DOMAIN, resolveGithubCopilotDomain } from "./domain.js"; import { createGithubCopilotDynamicModelHooks } from "./dynamic-models.js"; import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js"; -import { resolveCopilotExtendedThinkingLevels } from "./model-metadata.js"; +import { DEFAULT_COPILOT_MODEL, resolveCopilotExtendedThinkingLevels } from "./model-metadata.js"; import { PROVIDER_ID } from "./models.js"; import { buildGithubCopilotReplayPolicy, @@ -42,7 +42,6 @@ const COPILOT_ENV_VARS: [string, string, string] = [ "GH_TOKEN", "GITHUB_TOKEN", ]; -const DEFAULT_COPILOT_MODEL = "github-copilot/claude-opus-5"; const DEFAULT_COPILOT_PROFILE_ID = "github-copilot:github"; type GithubCopilotPluginConfig = { diff --git a/extensions/github-copilot/model-metadata.ts b/extensions/github-copilot/model-metadata.ts index 8d722a48046..64551f5449b 100644 --- a/extensions/github-copilot/model-metadata.ts +++ b/extensions/github-copilot/model-metadata.ts @@ -8,6 +8,8 @@ type CopilotReasoningCompat = { supportedReasoningEfforts?: readonly string[] | null; }; +export const DEFAULT_COPILOT_MODEL = "github-copilot/claude-opus-5"; + const COPILOT_CHAT_COMPLETIONS_COMPAT: ModelDefinitionConfig["compat"] = { supportsStore: false, supportsDeveloperRole: false, diff --git a/extensions/github-copilot/provider-auth.contract.test.ts b/extensions/github-copilot/provider-auth.contract.test.ts index c0c006fdaa5..75a6dfbb2f5 100644 --- a/extensions/github-copilot/provider-auth.contract.test.ts +++ b/extensions/github-copilot/provider-auth.contract.test.ts @@ -1,4 +1,5 @@ // Github Copilot tests cover provider auth.contract plugin behavior. import { describeGithubCopilotProviderAuthContract } from "openclaw/plugin-sdk/provider-test-contracts"; +import { DEFAULT_COPILOT_MODEL } from "./model-metadata.js"; -describeGithubCopilotProviderAuthContract(() => import("./index.js")); +describeGithubCopilotProviderAuthContract(() => import("./index.js"), DEFAULT_COPILOT_MODEL); diff --git a/extensions/github-copilot/provider-runtime.contract.test.ts b/extensions/github-copilot/provider-runtime.contract.test.ts index 38c9f728018..ba2da044b57 100644 --- a/extensions/github-copilot/provider-runtime.contract.test.ts +++ b/extensions/github-copilot/provider-runtime.contract.test.ts @@ -1,4 +1,8 @@ // Github Copilot tests cover provider runtime.contract plugin behavior. import { describeGithubCopilotProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; -describeGithubCopilotProviderRuntimeContract(() => import("./index.js")); +describeGithubCopilotProviderRuntimeContract( + () => import("./index.js"), + manifest.modelCatalog.providers["github-copilot"], +); diff --git a/extensions/google/provider-models.ts b/extensions/google/provider-models.ts index 14661b7b546..79e6c282807 100644 --- a/extensions/google/provider-models.ts +++ b/extensions/google/provider-models.ts @@ -3,7 +3,7 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; -import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared"; +import { resolveFamilyForwardCompatModel } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeGoogleModelId } from "./model-id.js"; @@ -71,38 +71,18 @@ export function isGoogleTextGenerationModelId(id: string): boolean { ); } -type GoogleForwardCompatFamily = { - googleTemplateIds: readonly string[]; - cliTemplateIds: readonly string[]; - antigravityTemplateIds?: readonly string[]; - preferExternalFirstForCli?: boolean; -}; +type GoogleForwardCompatFamily = readonly [ + googleTemplateIds: readonly string[], + cliTemplateIds: readonly string[], + antigravityTemplateIds?: readonly string[], + preferExternalFirstForCli?: boolean, +]; type GoogleTemplateSource = { - templateProviderId: string; + providerId?: string; templateIds: readonly string[]; }; -function cloneGoogleTemplateModel(params: { - providerId: string; - modelId: string; - templateProviderId: string; - templateIds: readonly string[]; - ctx: ProviderResolveDynamicModelContext; - patch?: Partial; -}): ProviderRuntimeModel | undefined { - return cloneFirstTemplateModel({ - providerId: params.templateProviderId, - modelId: params.modelId, - templateIds: params.templateIds, - ctx: params.ctx, - patch: { - ...params.patch, - provider: params.providerId, - }, - }); -} - function isGoogleGeminiCliProvider(providerId: string): boolean { return normalizeOptionalLowercaseString(providerId) === GOOGLE_GEMINI_CLI_PROVIDER_ID; } @@ -116,12 +96,12 @@ function templateIdsForProvider( family: GoogleForwardCompatFamily, ): readonly string[] { if (isGoogleGeminiCliProvider(templateProviderId)) { - return family.cliTemplateIds; + return family[1]; } if (isGoogleAntigravityProvider(templateProviderId)) { - return family.antigravityTemplateIds ?? family.googleTemplateIds; + return family[2] ?? family[0]; } - return family.googleTemplateIds; + return family[0]; } function buildGoogleTemplateSources(params: { @@ -135,8 +115,7 @@ function buildGoogleTemplateSources(params: { ? "google" : GOOGLE_GEMINI_CLI_PROVIDER_ID; const preferredExternalFirst = - isGoogleGeminiCliProvider(params.providerId) && - params.family.preferExternalFirstForCli === true; + isGoogleGeminiCliProvider(params.providerId) && params.family[3] === true; const orderedTemplateProviderIds = preferredExternalFirst ? [defaultTemplateProviderId, params.providerId] : [params.providerId, defaultTemplateProviderId]; @@ -150,13 +129,74 @@ function buildGoogleTemplateSources(params: { } seen.add(trimmed); sources.push({ - templateProviderId: trimmed, + providerId: trimmed, templateIds: templateIdsForProvider(trimmed, params.family), }); } return sources; } +type FamilyForwardCompatCase = Parameters< + typeof resolveFamilyForwardCompatModel +>[0]["cases"][number]; +type GoogleForwardCompatCase = Pick & { + family: GoogleForwardCompatFamily; +}; + +const GOOGLE_FORWARD_COMPAT_CASES: readonly GoogleForwardCompatCase[] = [ + { + match: (id) => id.startsWith(GEMINI_2_5_PRO_PREFIX), + family: [GEMINI_2_5_PRO_TEMPLATE_IDS, GEMINI_3_1_PRO_TEMPLATE_IDS, undefined, true], + }, + { + match: (id) => id.startsWith(GEMINI_2_5_FLASH_LITE_PREFIX), + family: [ + GEMINI_2_5_FLASH_LITE_TEMPLATE_IDS, + GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, + undefined, + true, + ], + }, + { + match: (id) => id.startsWith(GEMINI_2_5_FLASH_PREFIX), + family: [GEMINI_2_5_FLASH_TEMPLATE_IDS, GEMINI_3_1_FLASH_TEMPLATE_IDS, undefined, true], + }, + { + match: (id) => GEMINI_3_PRO_RE.test(id) || id === GEMINI_PRO_LATEST_ID, + family: [ + GEMINI_3_1_PRO_TEMPLATE_IDS, + GEMINI_3_1_PRO_TEMPLATE_IDS, + GEMINI_3_PRO_ANTIGRAVITY_TEMPLATE_IDS, + ], + patch: ({ providerId }) => + providerId === "google" || providerId === GOOGLE_GEMINI_CLI_PROVIDER_ID + ? { reasoning: true } + : undefined, + }, + { + match: (id) => GEMINI_3_FLASH_LITE_RE.test(id) || id === GEMINI_FLASH_LITE_LATEST_ID, + family: [ + GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, + GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, + GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS, + ], + }, + { + match: (id) => GEMINI_3_FLASH_RE.test(id) || id === GEMINI_FLASH_LATEST_ID, + family: [ + GEMINI_3_1_FLASH_TEMPLATE_IDS, + GEMINI_3_1_FLASH_TEMPLATE_IDS, + GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS, + ], + }, + { + match: (id) => id.startsWith(GEMMA_PREFIX), + family: [GEMMA_TEMPLATE_IDS, GEMMA_TEMPLATE_IDS], + patch: ({ normalizedModelId }) => + normalizedModelId.startsWith("gemma-4") ? { reasoning: true } : undefined, + }, +]; + export function resolveGoogleGeminiForwardCompatModel(params: { providerId: string; templateProviderId?: string; @@ -165,82 +205,22 @@ export function resolveGoogleGeminiForwardCompatModel(params: { const trimmed = normalizeGeminiProRequestId(params.ctx.modelId.trim()); const lower = normalizeOptionalLowercaseString(googleFamilyModelId(trimmed)) ?? ""; - if (!isGoogleTextGenerationModelId(lower)) { - return undefined; - } - - let family: GoogleForwardCompatFamily; - let patch: Partial | undefined; - if (lower.startsWith(GEMINI_2_5_PRO_PREFIX)) { - family = { - googleTemplateIds: GEMINI_2_5_PRO_TEMPLATE_IDS, - cliTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS, - preferExternalFirstForCli: true, - }; - } else if (lower.startsWith(GEMINI_2_5_FLASH_LITE_PREFIX)) { - family = { - googleTemplateIds: GEMINI_2_5_FLASH_LITE_TEMPLATE_IDS, - cliTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, - preferExternalFirstForCli: true, - }; - } else if (lower.startsWith(GEMINI_2_5_FLASH_PREFIX)) { - family = { - googleTemplateIds: GEMINI_2_5_FLASH_TEMPLATE_IDS, - cliTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS, - preferExternalFirstForCli: true, - }; - } else if (GEMINI_3_PRO_RE.test(lower) || lower === GEMINI_PRO_LATEST_ID) { - family = { - googleTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS, - cliTemplateIds: GEMINI_3_1_PRO_TEMPLATE_IDS, - antigravityTemplateIds: GEMINI_3_PRO_ANTIGRAVITY_TEMPLATE_IDS, - }; - if (params.providerId === "google" || params.providerId === GOOGLE_GEMINI_CLI_PROVIDER_ID) { - patch = { reasoning: true }; - } - } else if (GEMINI_3_FLASH_LITE_RE.test(lower) || lower === GEMINI_FLASH_LITE_LATEST_ID) { - family = { - googleTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, - cliTemplateIds: GEMINI_3_1_FLASH_LITE_TEMPLATE_IDS, - antigravityTemplateIds: GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS, - }; - } else if (GEMINI_3_FLASH_RE.test(lower) || lower === GEMINI_FLASH_LATEST_ID) { - family = { - googleTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS, - cliTemplateIds: GEMINI_3_1_FLASH_TEMPLATE_IDS, - antigravityTemplateIds: GEMINI_3_FLASH_ANTIGRAVITY_TEMPLATE_IDS, - }; - } else if (lower.startsWith(GEMMA_PREFIX)) { - family = { - googleTemplateIds: GEMMA_TEMPLATE_IDS, - cliTemplateIds: GEMMA_TEMPLATE_IDS, - }; - if (lower.startsWith("gemma-4")) { - patch = { reasoning: true }; - } - } else { - return undefined; - } - - for (const source of buildGoogleTemplateSources({ + return resolveFamilyForwardCompatModel({ providerId: params.providerId, - templateProviderId: params.templateProviderId, - family, - })) { - const model = cloneGoogleTemplateModel({ - providerId: params.providerId, - modelId: trimmed, - templateProviderId: source.templateProviderId, - templateIds: source.templateIds, - ctx: params.ctx, + modelId: trimmed, + normalizedModelId: isGoogleTextGenerationModelId(lower) ? lower : "", + ctx: params.ctx, + patch: { provider: params.providerId }, + cases: GOOGLE_FORWARD_COMPAT_CASES.map(({ family, match, patch }) => ({ + match, + templateSources: buildGoogleTemplateSources({ + providerId: params.providerId, + templateProviderId: params.templateProviderId, + family, + }), patch, - }); - if (model) { - return model; - } - } - - return undefined; + })), + }); } export function isModernGoogleModel(modelId: string): boolean { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 679e7307af7..b47336e1be8 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -12,8 +12,8 @@ import { import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import { DEFAULT_CONTEXT_TOKENS, - normalizeModelCompat, normalizeProviderId, + resolveFamilyForwardCompatModel, type ModelDefinitionConfig, type ModelProviderConfig, type ProviderPlugin, @@ -61,7 +61,6 @@ import manifest from "./openclaw.plugin.json" with { type: "json" }; import { buildOpenAIResponsesProviderHooks, buildOpenAISyntheticCatalogEntry, - cloneFirstTemplateModel, findCatalogTemplate, matchesExactOrPrefix, OPENAI_DEFAULT_RUNTIME_CONTEXT_TOKENS, @@ -718,144 +717,89 @@ function buildOpenAIUnknownModelHint(modelId: string): string | undefined { return "gpt-5.3-codex-spark is available only through ChatGPT/Codex OAuth. Run `openclaw models auth login --provider openai` and use openai/gpt-5.3-codex-spark with that OAuth profile; OpenAI API-key auth cannot use this model."; } -function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelContext) { - const trimmedModelId = ctx.modelId.trim(); - const lower = normalizeLowercaseStringOrEmpty(trimmedModelId); - let templateIds: readonly string[]; - let patch: Partial; - if (lower === OPENAI_CHAT_LATEST_MODEL_ID) { - templateIds = OPENAI_CHAT_LATEST_TEMPLATE_MODEL_IDS; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: false, - input: ["text", "image"], - cost: OPENAI_CHAT_LATEST_COST, - contextWindow: 400_000, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else if ( - lower === OPENAI_GPT_56_MODEL_ID || - lower === OPENAI_GPT_56_SOL_MODEL_ID || - lower === OPENAI_GPT_56_TERRA_MODEL_ID || - lower === OPENAI_GPT_56_LUNA_MODEL_ID - ) { - templateIds = OPENAI_GPT_56_TEMPLATE_MODEL_IDS; - const cost = - lower === OPENAI_GPT_56_MODEL_ID || lower === OPENAI_GPT_56_SOL_MODEL_ID - ? OPENAI_GPT_56_SOL_COST - : lower === OPENAI_GPT_56_TERRA_MODEL_ID - ? OPENAI_GPT_56_TERRA_COST - : OPENAI_GPT_56_LUNA_COST; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], - cost, - contextWindow: OPENAI_GPT_56_DIRECT_CONTEXT_WINDOW, - contextTokens: OPENAI_DEFAULT_RUNTIME_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - thinkingLevelMap: OPENAI_GPT_56_THINKING_LEVEL_MAP, - }; - } else if (lower === OPENAI_GPT_55_MODEL_ID) { - templateIds = [OPENAI_GPT_55_MODEL_ID, OPENAI_GPT_54_MODEL_ID]; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], +const OPENAI_GPT_FORWARD_COMPAT_CASES = [ + { + match: [OPENAI_CHAT_LATEST_MODEL_ID], + templateIds: OPENAI_CHAT_LATEST_TEMPLATE_MODEL_IDS, + patch: { reasoning: false, cost: OPENAI_CHAT_LATEST_COST, contextWindow: 400_000 }, + }, + { + match: [ + OPENAI_GPT_56_MODEL_ID, + OPENAI_GPT_56_SOL_MODEL_ID, + OPENAI_GPT_56_TERRA_MODEL_ID, + OPENAI_GPT_56_LUNA_MODEL_ID, + ], + templateIds: OPENAI_GPT_56_TEMPLATE_MODEL_IDS, + patch: ({ normalizedModelId: id }) => + ({ + cost: + id === OPENAI_GPT_56_MODEL_ID || id === OPENAI_GPT_56_SOL_MODEL_ID + ? OPENAI_GPT_56_SOL_COST + : id === OPENAI_GPT_56_TERRA_MODEL_ID + ? OPENAI_GPT_56_TERRA_COST + : OPENAI_GPT_56_LUNA_COST, + contextWindow: OPENAI_GPT_56_DIRECT_CONTEXT_WINDOW, + contextTokens: OPENAI_DEFAULT_RUNTIME_CONTEXT_TOKENS, + thinkingLevelMap: OPENAI_GPT_56_THINKING_LEVEL_MAP, + }) satisfies Partial, + }, + { + match: [OPENAI_GPT_55_MODEL_ID], + templateIds: [OPENAI_GPT_55_MODEL_ID, OPENAI_GPT_54_MODEL_ID], + patch: { mediaInput: OPENAI_GPT_55_MEDIA_INPUT, cost: OPENAI_GPT_55_COST, contextWindow: OPENAI_GPT_55_CONTEXT_WINDOW, contextTokens: OPENAI_DEFAULT_RUNTIME_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else if (lower === OPENAI_GPT_55_PRO_MODEL_ID) { - templateIds = OPENAI_GPT_55_PRO_TEMPLATE_MODEL_IDS; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], + }, + }, + { + match: [OPENAI_GPT_55_PRO_MODEL_ID], + templateIds: OPENAI_GPT_55_PRO_TEMPLATE_MODEL_IDS, + patch: { cost: OPENAI_GPT_55_PRO_COST, contextWindow: OPENAI_GPT_55_PRO_CONTEXT_WINDOW, contextTokens: OPENAI_DEFAULT_RUNTIME_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else if (lower === OPENAI_GPT_54_MODEL_ID) { - templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], - cost: OPENAI_GPT_54_COST, - contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) { - templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], - cost: OPENAI_GPT_54_PRO_COST, - contextWindow: OPENAI_GPT_54_PRO_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else if (lower === OPENAI_GPT_54_MINI_MODEL_ID) { - templateIds = OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], - cost: OPENAI_GPT_54_MINI_COST, - contextWindow: OPENAI_GPT_54_MINI_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else if (lower === OPENAI_GPT_54_NANO_MODEL_ID) { - templateIds = OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS; - patch = { - api: "openai-responses", - provider: PROVIDER_ID, - baseUrl: resolveOpenAIDefaultBaseUrl(), - reasoning: true, - input: ["text", "image"], - cost: OPENAI_GPT_54_NANO_COST, - contextWindow: OPENAI_GPT_54_NANO_CONTEXT_TOKENS, - maxTokens: OPENAI_GPT_54_MAX_TOKENS, - }; - } else { - return undefined; - } + }, + }, + { + match: [OPENAI_GPT_54_MODEL_ID], + templateIds: OPENAI_GPT_54_TEMPLATE_MODEL_IDS, + patch: { cost: OPENAI_GPT_54_COST, contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS }, + }, + { + match: [OPENAI_GPT_54_PRO_MODEL_ID], + templateIds: OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS, + patch: { cost: OPENAI_GPT_54_PRO_COST, contextWindow: OPENAI_GPT_54_PRO_CONTEXT_TOKENS }, + }, + { + match: [OPENAI_GPT_54_MINI_MODEL_ID], + templateIds: OPENAI_GPT_54_MINI_TEMPLATE_MODEL_IDS, + patch: { cost: OPENAI_GPT_54_MINI_COST, contextWindow: OPENAI_GPT_54_MINI_CONTEXT_TOKENS }, + }, + { + match: [OPENAI_GPT_54_NANO_MODEL_ID], + templateIds: OPENAI_GPT_54_NANO_TEMPLATE_MODEL_IDS, + patch: { cost: OPENAI_GPT_54_NANO_COST, contextWindow: OPENAI_GPT_54_NANO_CONTEXT_TOKENS }, + }, +] satisfies Parameters[0]["cases"]; - return ( - cloneFirstTemplateModel({ - providerId: PROVIDER_ID, - modelId: trimmedModelId, - templateIds, - ctx, - patch, - }) ?? - normalizeModelCompat({ - id: trimmedModelId, - name: trimmedModelId, - ...patch, - cost: patch.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: patch.contextWindow ?? DEFAULT_CONTEXT_TOKENS, - maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_TOKENS, - } as ProviderRuntimeModel) - ); +function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelContext) { + return resolveFamilyForwardCompatModel({ + providerId: PROVIDER_ID, + ctx, + cases: OPENAI_GPT_FORWARD_COMPAT_CASES, + patch: { + api: "openai-responses", + provider: PROVIDER_ID, + baseUrl: resolveOpenAIDefaultBaseUrl(), + reasoning: true, + input: ["text", "image"], + maxTokens: OPENAI_GPT_54_MAX_TOKENS, + }, + synthesize: true, + }); } export function buildOpenAIProvider(): ProviderPlugin { diff --git a/extensions/openai/provider-runtime.contract.test.ts b/extensions/openai/provider-runtime.contract.test.ts index 2f207195708..6e6747fcd22 100644 --- a/extensions/openai/provider-runtime.contract.test.ts +++ b/extensions/openai/provider-runtime.contract.test.ts @@ -1,4 +1,8 @@ // Openai tests cover provider runtime.contract plugin behavior. import { describeOpenAIProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; -describeOpenAIProviderRuntimeContract(() => import("./index.js")); +describeOpenAIProviderRuntimeContract( + () => import("./index.js"), + manifest.modelCatalog.providers.openai, +); diff --git a/extensions/venice/provider-runtime.contract.test.ts b/extensions/venice/provider-runtime.contract.test.ts index 0a6137a355b..808cd068dbd 100644 --- a/extensions/venice/provider-runtime.contract.test.ts +++ b/extensions/venice/provider-runtime.contract.test.ts @@ -1,4 +1,8 @@ // Venice tests cover provider runtime.contract plugin behavior. import { describeVeniceProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts"; +import manifest from "./openclaw.plugin.json" with { type: "json" }; -describeVeniceProviderRuntimeContract(() => import("./index.js")); +describeVeniceProviderRuntimeContract( + () => import("./index.js"), + manifest.modelCatalog.providers.venice, +); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 183a88779d2..c4864272542 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -8,7 +8,6 @@ import { type ProviderAuthMethod, type ProviderAuthMethodNonInteractiveContext, type ProviderResolveDynamicModelContext, - type ProviderRuntimeModel, type ProviderWrapStreamFnContext, } from "openclaw/plugin-sdk/plugin-entry"; import { @@ -25,7 +24,7 @@ import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provid import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared"; import { buildProviderReplayFamilyHooks, - normalizeModelCompat, + resolveFamilyForwardCompatModel, } from "openclaw/plugin-sdk/provider-model-shared"; import { createPayloadPatchStreamWrapper, @@ -90,41 +89,34 @@ async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams) } } -function resolveGlm5ForwardCompatModel( - ctx: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - const trimmedModelId = ctx.modelId.trim(); - if (!normalizeLowercaseStringOrEmpty(trimmedModelId).startsWith("glm-5")) { - return undefined; - } - - const existing = ctx.modelRegistry.find( - PROVIDER_ID, - trimmedModelId, - ) as ProviderRuntimeModel | null; - if (existing) { - return existing; - } - - const def = buildZaiModelDefinition({ id: trimmedModelId }); - const template = ctx.modelRegistry.find( - PROVIDER_ID, - GLM5_TEMPLATE_MODEL_ID, - ) as ProviderRuntimeModel | null; - return normalizeModelCompat({ - ...template, - id: def.id, - name: def.name, - // Native models must never fall through to the OpenAI SDK's default host. - baseUrl: ctx.providerConfig?.baseUrl ?? template?.baseUrl ?? resolveZaiBaseUrl(), - api: "openai-completions", - provider: PROVIDER_ID, - reasoning: def.reasoning, - input: def.input, - cost: def.cost, - contextWindow: def.contextWindow, - maxTokens: def.maxTokens, - } as ProviderRuntimeModel); +function resolveGlm5ForwardCompatModel(ctx: ProviderResolveDynamicModelContext) { + return resolveFamilyForwardCompatModel({ + providerId: PROVIDER_ID, + ctx, + cases: [ + { + match: (id) => id.startsWith("glm-5"), + templateIds: [GLM5_TEMPLATE_MODEL_ID], + patch: ({ modelId, template }) => { + const def = buildZaiModelDefinition({ id: modelId }); + return { + name: def.name, + // Native models must never fall through to the OpenAI SDK's default host. + baseUrl: ctx.providerConfig?.baseUrl ?? template?.baseUrl ?? resolveZaiBaseUrl(), + api: "openai-completions", + provider: PROVIDER_ID, + reasoning: def.reasoning, + input: def.input as ("text" | "image")[], + cost: def.cost, + contextWindow: def.contextWindow, + maxTokens: def.maxTokens, + }; + }, + }, + ], + preserveExisting: true, + synthesize: true, + }); } function isTrueParam(value: unknown): boolean { diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index a8bb00c4faa..b8f48d123f0 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -175,6 +175,7 @@ export { export { cloneFirstTemplateModel, matchesExactOrPrefix, + resolveFamilyForwardCompatModel, } from "../plugins/provider-model-helpers.js"; import { normalizeOptionalLowercaseString } from "../../packages/normalization-core/src/string-coerce.js"; diff --git a/src/plugin-sdk/test-helpers/provider-auth-contract.ts b/src/plugin-sdk/test-helpers/provider-auth-contract.ts index 94e978aacea..3baf9b01953 100644 --- a/src/plugin-sdk/test-helpers/provider-auth-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-auth-contract.ts @@ -298,7 +298,10 @@ export function describeOpenAICodexProviderAuthContract( }); } -export function describeGithubCopilotProviderAuthContract(load: ProviderAuthContractPluginLoader) { +export function describeGithubCopilotProviderAuthContract( + load: ProviderAuthContractPluginLoader, + defaultModel: string, +) { const state = { authStore: { version: 1, profiles: {} } as AuthProfileStore, }; @@ -341,7 +344,7 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont }, }, ], - defaultModel: "github-copilot/claude-opus-5", + defaultModel, }); } finally { if (previousIsTTYDescriptor) { @@ -424,7 +427,7 @@ export function describeGithubCopilotProviderAuthContract(load: ProviderAuthCont }, }, ], - defaultModel: "github-copilot/claude-opus-5", + defaultModel, }); // Credential is sourced from the device flow response, not from the existing // on-disk auth store. ensureAuthProfileStore is still called by the diff --git a/src/plugin-sdk/test-helpers/provider-runtime-contract.ts b/src/plugin-sdk/test-helpers/provider-runtime-contract.ts index 66e9e0cc8d1..d536b8e4c0e 100644 --- a/src/plugin-sdk/test-helpers/provider-runtime-contract.ts +++ b/src/plugin-sdk/test-helpers/provider-runtime-contract.ts @@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderRuntimeModel } from "../plugin-entry.js"; import { registerProviderPlugin, requireRegisteredProvider } from "../plugin-test-runtime.js"; +import { buildManifestModelProviderConfig } from "../provider-catalog-shared.js"; import type { ProviderPlugin } from "../provider-model-shared.js"; import { createProviderUsageFetch, makeResponse } from "../test-env.js"; @@ -36,6 +37,31 @@ function createModel(overrides: Partial & Pick = {}): ProviderRuntimeModel => { + const model = provider.models.find((candidate) => candidate.id === modelId); + if (!model) { + throw new Error(`Missing ${providerId} manifest model ${modelId}`); + } + const input = model.input.filter( + (item): item is "text" | "image" => item === "text" || item === "image", + ); + if (input.length !== model.input.length) { + throw new Error(`Unsupported ${providerId} manifest model input for ${modelId}`); + } + return createModel({ + ...model, + id: model.id, + provider: providerId, + baseUrl: model.baseUrl ?? provider.baseUrl, + api: model.api ?? provider.api, + input, + ...overrides, + }); + }; +} + function requireRecord(value: unknown, label: string): Record { expect(value, label).toBeTypeOf("object"); expect(value, label).not.toBeNull(); @@ -131,6 +157,7 @@ export function describeAnthropicProviderRuntimeContract( it("owns anthropic 4.6 forward-compat resolution", () => { const provider = requireProviderContractProvider("anthropic"); + // The dated 4.6 template has no owning manifest row; keep its remap fixture literal. const model = provider.resolveDynamicModel?.({ provider: "anthropic", modelId: "claude-sonnet-4.6-20260219", @@ -240,6 +267,7 @@ export function describeAnthropicProviderRuntimeContract( export function describeGithubCopilotProviderRuntimeContract( load: ProviderRuntimeContractPluginLoader, + manifestCatalog: unknown, ) { describe( "github-copilot provider runtime contract", @@ -253,30 +281,21 @@ export function describeGithubCopilotProviderRuntimeContract( load, }, ]); + const createManifestModel = createManifestModelFactory("github-copilot", manifestCatalog); it("owns Copilot-specific forward-compat fallbacks", () => { const provider = requireProviderContractProvider("github-copilot"); + const expected = createManifestModel("gpt-5.4"); const model = provider.resolveDynamicModel?.({ provider: "github-copilot", modelId: "gpt-5.4", modelRegistry: { - find: (_provider: string, id: string) => - id === "gpt-5.2-codex" - ? createModel({ - id, - api: "openai-chatgpt-responses", - provider: "github-copilot", - baseUrl: "https://api.copilot.example", - }) - : null, + find: () => null, } as never, }); - expectFields(model, { - id: "gpt-5.4", - provider: "github-copilot", - api: "openai-responses", - }); + const { baseUrl: _providerDefault, ...manifestFields } = expected; + expectFields(model, manifestFields); }); }, ); @@ -290,6 +309,7 @@ export function describeGoogleProviderRuntimeContract(load: ProviderRuntimeContr it("owns google direct gemini 3.1 forward-compat resolution", () => { const provider = requireProviderContractProvider("google"); + // Google catalog rows are runtime-discovered, so the retired 3.0 template stays literal. const model = provider.resolveDynamicModel?.({ provider: "google", modelId: "gemini-3.1-pro-preview", @@ -414,8 +434,12 @@ export function describeGoogleProviderRuntimeContract(load: ProviderRuntimeContr }); } -export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContractPluginLoader) { +export function describeOpenAIProviderRuntimeContract( + load: ProviderRuntimeContractPluginLoader, + manifestCatalog: unknown, +) { describe("openai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + const createManifestModel = createManifestModelFactory("openai", manifestCatalog); const codexProviderConfig = { api: "openai-chatgpt-responses", baseUrl: "https://chatgpt.com/backend-api/codex", @@ -426,6 +450,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr it("owns openai gpt-5.4 forward-compat resolution", () => { const provider = requireProviderContractProvider("openai"); + // Neither gpt-5.4-pro nor its 5.2 template has an owning manifest row. const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4-pro", @@ -454,38 +479,28 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr it("owns openai gpt-5.5 forward-compat resolution", () => { const provider = requireProviderContractProvider("openai"); + const expected = createManifestModel("gpt-5.5", { name: "gpt-5.5" }); + // OpenAI has no gpt-5.4 manifest row; distinct literal metadata keeps the upgrade asserted. + const olderTemplate = createModel({ + id: "gpt-5.4", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + input: ["text", "image"], + }); const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.5", modelRegistry: { - find: (_provider: string, id: string) => - id === "gpt-5.4" - ? createModel({ - id, - provider: "openai", - baseUrl: "https://api.openai.com/v1", - input: ["text", "image"], - }) - : null, + find: (_provider: string, id: string) => (id === olderTemplate.id ? olderTemplate : null), } as never, }); - expectFields(model, { - id: "gpt-5.5", - provider: "openai", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - contextWindow: 1_050_000, - contextTokens: 272_000, - maxTokens: 128_000, - mediaInput: { - image: { maxSidePx: 6000, preferredSidePx: 2048, tokenMode: "detail" }, - }, - }); + expectFields(model, expected); }); it("owns openai gpt-5.4 mini forward-compat resolution", () => { const provider = requireProviderContractProvider("openai"); + // The OpenAI manifest has no gpt-5.4-mini row, so its family patch stays literal. const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4-mini", @@ -518,6 +533,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr it("owns direct openai transport normalization", () => { const provider = requireProviderContractProvider("openai"); + // The normalized gpt-5.4 input is intentionally outside the current OpenAI manifest. expectFields( provider.normalizeResolvedModel?.({ provider: "openai", @@ -557,6 +573,7 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr it("owns forward-compat codex models", () => { const provider = requireProviderContractProvider("openai"); + // Codex gpt-5.4 has no OpenAI manifest row; this keeps its transport mapping independent. const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4", @@ -586,58 +603,45 @@ export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContr it("keeps OpenClaw cost metadata but applies Codex context metadata for gpt-5.5 models", () => { const provider = requireProviderContractProvider("openai"); + const manifestModel = createManifestModel("gpt-5.5", { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + contextWindow: 272_000, + }); const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.5", authProfileMode: "oauth", providerConfig: codexProviderConfig, modelRegistry: { - find: (_provider: string, id: string) => - id === "gpt-5.5" - ? createModel({ - id, - api: "openai-chatgpt-responses", - provider: "openai", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text", "image"], - cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - contextWindow: 272_000, - maxTokens: 128_000, - }) - : null, + find: (_provider: string, id: string) => (id === "gpt-5.5" ? manifestModel : null), } as never, }); expectFields(model, { - id: "gpt-5.5", - provider: "openai", + ...manifestModel, api: "openai-chatgpt-responses", + baseUrl: codexProviderConfig.baseUrl, contextWindow: 400_000, - contextTokens: 272_000, - maxTokens: 128_000, }); }); it("claims codex mini models through the Codex OAuth route", () => { const provider = requireProviderContractProvider("openai"); + const manifestTemplate = createManifestModel("gpt-5.5", { + id: "gpt-5.4", + name: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + contextWindow: 272_000, + }); const model = provider.resolveDynamicModel?.({ provider: "openai", modelId: "gpt-5.4-mini", authProfileMode: "oauth", providerConfig: codexProviderConfig, modelRegistry: { - find: (_provider: string, id: string) => - id === "gpt-5.4" - ? createModel({ - id, - api: "openai-chatgpt-responses", - provider: "openai", - baseUrl: "https://chatgpt.com/backend-api", - cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 }, - contextWindow: 272_000, - maxTokens: 128_000, - }) - : null, + find: (_provider: string, id: string) => (id === "gpt-5.4" ? manifestTemplate : null), } as never, }); @@ -720,6 +724,7 @@ export function describeOpenRouterProviderRuntimeContract( it("owns dynamic OpenRouter model defaults", () => { const provider = requireProviderContractProvider("openrouter"); + // OpenRouter owns a runtime-discovered catalog, so these synthetic defaults stay literal. const model = provider.resolveDynamicModel?.({ provider: "openrouter", modelId: "x-ai/grok-4-1-fast", @@ -739,27 +744,30 @@ export function describeOpenRouterProviderRuntimeContract( }); } -export function describeVeniceProviderRuntimeContract(load: ProviderRuntimeContractPluginLoader) { +export function describeVeniceProviderRuntimeContract( + load: ProviderRuntimeContractPluginLoader, + manifestCatalog: unknown, +) { describe("venice provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => { + const createManifestModel = createManifestModelFactory("venice", manifestCatalog); const requireProviderContractProvider = installRuntimeHooks([ { providerIds: ["venice"], pluginId: "venice", name: "Venice", load }, ]); it("owns xai downstream compat flags for grok-backed Venice models", () => { const provider = requireProviderContractProvider("venice"); + const manifestModel = createManifestModel("grok-4-5"); const model = provider.normalizeResolvedModel?.({ provider: "venice", - modelId: "grok-4-3", - model: createModel({ - id: "grok-4-3", - provider: "venice", - api: "openai-completions", - baseUrl: "https://api.venice.ai/api/v1", - }), + modelId: manifestModel.id, + model: manifestModel, + }); + const { compat: _manifestCompat, ...manifestFields } = manifestModel; + expectFields(model, manifestFields); + expect(requireRecord(model?.compat, "compat")).toMatchObject({ + toolSchemaProfile: "xai", + toolCallArgumentsEncoding: "html-entities", }); - const compat = requireRecord(model?.compat, "compat"); - expect(compat.toolSchemaProfile).toBe("xai"); - expect(compat.toolCallArgumentsEncoding).toBe("html-entities"); }); }); } @@ -772,6 +780,7 @@ export function describeZAIProviderRuntimeContract(load: ProviderRuntimeContract it("owns glm-5 forward-compat resolution", () => { const provider = requireProviderContractProvider("zai"); + // Neither the synthetic glm-5 id nor its glm-4.7 template has a current manifest row. const model = provider.resolveDynamicModel?.({ provider: "zai", modelId: "glm-5", diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 91e37009025..baf952e5b42 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -1210,7 +1210,12 @@ describe("plugin-sdk subpath exports", () => { ]); expectSourceOmits("core", ["buildOauthProviderAuthResult"]); expectSourceContract("provider-model-shared", { - mentions: ["DEFAULT_CONTEXT_TOKENS", "normalizeModelCompat", "cloneFirstTemplateModel"], + mentions: [ + "DEFAULT_CONTEXT_TOKENS", + "normalizeModelCompat", + "cloneFirstTemplateModel", + "resolveFamilyForwardCompatModel", + ], omits: ["applyOpenAIConfig", "buildKilocodeModelDefinition", "discoverHuggingfaceModels"], }); expectSourceContract("provider-catalog-shared", { diff --git a/src/plugins/provider-model-helpers.test.ts b/src/plugins/provider-model-helpers.test.ts index 74ccc6701b2..550c2f40d3c 100644 --- a/src/plugins/provider-model-helpers.test.ts +++ b/src/plugins/provider-model-helpers.test.ts @@ -1,7 +1,11 @@ // Covers provider model helper behavior for plugin model registries. import type { ModelRegistry } from "openclaw/plugin-sdk/agent-sessions"; import { describe, expect, it } from "vitest"; -import { cloneFirstTemplateModel, matchesExactOrPrefix } from "./provider-model-helpers.js"; +import { + cloneFirstTemplateModel, + matchesExactOrPrefix, + resolveFamilyForwardCompatModel, +} from "./provider-model-helpers.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import type { ProviderResolveDynamicModelContext } from "./types.js"; @@ -113,3 +117,60 @@ describe("matchesExactOrPrefix", () => { }, ] as const)("matches $id against prefixes", expectPrefixMatchCase); }); + +describe("resolveFamilyForwardCompatModel", () => { + it("selects the first matching family and ordered cross-provider template", () => { + const ctx = createContext([ + createTemplateModel("template-b", { provider: "template-provider", reasoning: false }), + ]); + + expect( + resolveFamilyForwardCompatModel({ + providerId: "test-provider", + ctx, + cases: [ + { + match: (id) => id.startsWith("next-"), + templateSources: [ + { templateIds: ["missing"] }, + { providerId: "template-provider", templateIds: ["template-b"] }, + ], + patch: { provider: "test-provider", reasoning: true }, + }, + ], + }), + ).toMatchObject({ + id: "next-model", + name: "next-model", + provider: "test-provider", + reasoning: true, + }); + }); + + it("synthesizes a normalized model when a matched family has no template", () => { + expect( + resolveFamilyForwardCompatModel({ + providerId: "test-provider", + ctx: createContext([]), + cases: [ + { + match: (id) => id === "next-model", + templateIds: ["missing"], + patch: ({ normalizedModelId }) => ({ + api: "openai-responses", + provider: "test-provider", + reasoning: normalizedModelId === "next-model", + }), + }, + ], + synthesize: true, + }), + ).toMatchObject({ + id: "next-model", + name: "next-model", + provider: "test-provider", + api: "openai-responses", + reasoning: true, + }); + }); +}); diff --git a/src/plugins/provider-model-helpers.ts b/src/plugins/provider-model-helpers.ts index 4751f082155..13c738e790d 100644 --- a/src/plugins/provider-model-helpers.ts +++ b/src/plugins/provider-model-helpers.ts @@ -1,10 +1,31 @@ // Normalizes provider model metadata from plugin manifests and hooks. import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; +import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js"; import { normalizeModelCompat } from "./provider-model-compat.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import type { ProviderResolveDynamicModelContext } from "./types.js"; +type FamilyForwardCompatTemplateSource = { + providerId?: string; + templateIds: readonly string[]; +}; +type FamilyForwardCompatContext = { + modelId: string; + normalizedModelId: string; + providerId: string; + template?: ProviderRuntimeModel; +}; + +type FamilyForwardCompatCase = { + match: readonly string[] | ((normalizedModelId: string) => boolean); + patch?: + | Partial + | ((context: FamilyForwardCompatContext) => Partial | undefined); + templateIds?: readonly string[]; + templateSources?: readonly FamilyForwardCompatTemplateSource[]; +}; + /** True when an id matches a normalized exact value or value prefix. */ export function matchesExactOrPrefix(id: string, values: readonly string[]): boolean { const normalizedId = normalizeLowercaseStringOrEmpty(id); @@ -22,21 +43,82 @@ export function cloneFirstTemplateModel(params: { ctx: ProviderResolveDynamicModelContext; patch?: Partial; }): ProviderRuntimeModel | undefined { - const trimmedModelId = params.modelId.trim(); - for (const templateId of uniqueStrings(params.templateIds).filter(Boolean)) { - const template = params.ctx.modelRegistry.find( - params.providerId, - templateId, - ) as ProviderRuntimeModel | null; - if (!template) { - continue; - } - return normalizeModelCompat({ - ...template, - id: trimmedModelId, - name: trimmedModelId, - ...params.patch, - } as ProviderRuntimeModel); - } - return undefined; + return resolveFamilyForwardCompatModel({ + providerId: params.providerId, + modelId: params.modelId, + ctx: params.ctx, + cases: [{ match: () => true, templateIds: params.templateIds }], + patch: params.patch, + }); +} + +export function resolveFamilyForwardCompatModel(params: { + providerId: string; + ctx: ProviderResolveDynamicModelContext; + cases: readonly FamilyForwardCompatCase[]; + modelId?: string; + normalizedModelId?: string; + patch?: Partial; + preserveExisting?: boolean; + synthesize?: boolean; +}): ProviderRuntimeModel | undefined { + const modelId = (params.modelId ?? params.ctx.modelId).trim(); + const normalizedModelId = params.normalizedModelId ?? normalizeLowercaseStringOrEmpty(modelId); + const family = params.cases.find((candidate) => + typeof candidate.match === "function" + ? candidate.match(normalizedModelId) + : candidate.match.includes(normalizedModelId), + ); + if (!family) { + return undefined; + } + const existing = params.preserveExisting + ? (params.ctx.modelRegistry.find(params.providerId, modelId) as ProviderRuntimeModel | null) + : null; + if (existing) { + return existing; + } + + const context: FamilyForwardCompatContext = { + modelId, + normalizedModelId, + providerId: params.providerId, + }; + const resolvePatch = (template?: ProviderRuntimeModel) => { + const patchContext = { ...context, template }; + const familyPatch = + typeof family.patch === "function" ? family.patch(patchContext) : family.patch; + return { ...params.patch, ...familyPatch }; + }; + const templateSources = family.templateSources ?? [{ templateIds: family.templateIds ?? [] }]; + + for (const source of templateSources) { + for (const templateId of uniqueStrings(source.templateIds).filter(Boolean)) { + const template = params.ctx.modelRegistry.find( + source.providerId ?? params.providerId, + templateId, + ) as ProviderRuntimeModel | null; + if (template) { + return normalizeModelCompat({ + ...template, + id: modelId, + name: modelId, + ...resolvePatch(template), + } as ProviderRuntimeModel); + } + } + } + + if (!params.synthesize) { + return undefined; + } + const patch = resolvePatch(); + return normalizeModelCompat({ + id: modelId, + name: modelId, + ...patch, + cost: patch?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, + } as ProviderRuntimeModel); }