mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-10 17:16:55 +00:00
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:
committed by
GitHub
parent
463a57c8e0
commit
caa01f4f5b
@@ -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` |
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user