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
This commit is contained in:
Peter Steinberger
2026-07-25 18:09:37 -07:00
committed by GitHub
parent 463a57c8e0
commit caa01f4f5b
16 changed files with 473 additions and 382 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>;
}): 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<FamilyForwardCompatCase, "match" | "patch"> & {
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<ProviderRuntimeModel> | 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 {

View File

@@ -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<ProviderRuntimeModel>;
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<ProviderRuntimeModel>,
},
{
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<typeof resolveFamilyForwardCompatModel>[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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ProviderRuntimeModel> & Pick<ProviderRun
} satisfies ProviderRuntimeModel;
}
function createManifestModelFactory(providerId: string, catalog: unknown) {
const provider = buildManifestModelProviderConfig({ providerId, catalog });
return (modelId: string, overrides: Partial<ProviderRuntimeModel> = {}): 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<string, unknown> {
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",

View File

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

View File

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

View File

@@ -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<ProviderRuntimeModel>
| ((context: FamilyForwardCompatContext) => Partial<ProviderRuntimeModel> | 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>;
}): 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<ProviderRuntimeModel>;
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);
}