mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 17:11:46 +00:00
feat(plugins): merge openai vendor seams into one plugin
This commit is contained in:
@@ -6,6 +6,7 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types
|
||||
import { coerceSecretRef } from "../config/types.secrets.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js";
|
||||
import {
|
||||
normalizeOptionalSecretInput,
|
||||
normalizeSecretInput,
|
||||
@@ -358,13 +359,19 @@ export async function resolveApiKeyForProvider(params: {
|
||||
return resolveAwsSdkAuthInfo();
|
||||
}
|
||||
|
||||
if (provider === "openai") {
|
||||
const hasCodex = listProfilesForProvider(store, "openai-codex").length > 0;
|
||||
if (hasCodex) {
|
||||
throw new Error(
|
||||
'No API key found for provider "openai". You are authenticated with OpenAI Codex OAuth. Use openai-codex/gpt-5.4 (OAuth) or set OPENAI_API_KEY to use openai/gpt-5.4.',
|
||||
);
|
||||
}
|
||||
const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({
|
||||
provider,
|
||||
config: cfg,
|
||||
context: {
|
||||
config: cfg,
|
||||
agentDir: params.agentDir,
|
||||
env: process.env,
|
||||
provider,
|
||||
listProfileIds: (providerId) => listProfilesForProvider(store, providerId),
|
||||
},
|
||||
});
|
||||
if (pluginMissingAuthMessage) {
|
||||
throw new Error(pluginMissingAuthMessage);
|
||||
}
|
||||
|
||||
const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type OpenClawConfig, loadConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { shouldSuppressBuiltInModel } from "./model-suppression.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
@@ -33,70 +34,8 @@ let hasLoggedModelCatalogError = false;
|
||||
const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
|
||||
let importPiSdk = defaultImportPiSdk;
|
||||
|
||||
const CODEX_PROVIDER = "openai-codex";
|
||||
const OPENAI_PROVIDER = "openai";
|
||||
const OPENAI_GPT54_MODEL_ID = "gpt-5.4";
|
||||
const OPENAI_GPT54_PRO_MODEL_ID = "gpt-5.4-pro";
|
||||
const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex";
|
||||
const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
|
||||
const OPENAI_CODEX_GPT54_MODEL_ID = "gpt-5.4";
|
||||
const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]);
|
||||
|
||||
type SyntheticCatalogFallback = {
|
||||
provider: string;
|
||||
id: string;
|
||||
templateIds: readonly string[];
|
||||
};
|
||||
|
||||
const SYNTHETIC_CATALOG_FALLBACKS: readonly SyntheticCatalogFallback[] = [
|
||||
{
|
||||
provider: OPENAI_PROVIDER,
|
||||
id: OPENAI_GPT54_MODEL_ID,
|
||||
templateIds: ["gpt-5.2"],
|
||||
},
|
||||
{
|
||||
provider: OPENAI_PROVIDER,
|
||||
id: OPENAI_GPT54_PRO_MODEL_ID,
|
||||
templateIds: ["gpt-5.2-pro", "gpt-5.2"],
|
||||
},
|
||||
{
|
||||
provider: CODEX_PROVIDER,
|
||||
id: OPENAI_CODEX_GPT54_MODEL_ID,
|
||||
templateIds: ["gpt-5.3-codex", "gpt-5.2-codex"],
|
||||
},
|
||||
{
|
||||
provider: CODEX_PROVIDER,
|
||||
id: OPENAI_CODEX_GPT53_SPARK_MODEL_ID,
|
||||
templateIds: [OPENAI_CODEX_GPT53_MODEL_ID],
|
||||
},
|
||||
] as const;
|
||||
|
||||
function applySyntheticCatalogFallbacks(models: ModelCatalogEntry[]): void {
|
||||
const findCatalogEntry = (provider: string, id: string) =>
|
||||
models.find(
|
||||
(entry) =>
|
||||
entry.provider.toLowerCase() === provider.toLowerCase() &&
|
||||
entry.id.toLowerCase() === id.toLowerCase(),
|
||||
);
|
||||
|
||||
for (const fallback of SYNTHETIC_CATALOG_FALLBACKS) {
|
||||
if (findCatalogEntry(fallback.provider, fallback.id)) {
|
||||
continue;
|
||||
}
|
||||
const template = fallback.templateIds
|
||||
.map((templateId) => findCatalogEntry(fallback.provider, templateId))
|
||||
.find((entry) => entry !== undefined);
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
models.push({
|
||||
...template,
|
||||
id: fallback.id,
|
||||
name: fallback.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
@@ -256,7 +195,31 @@ export async function loadModelCatalog(params?: {
|
||||
models.push({ id, name, provider, contextWindow, reasoning, input });
|
||||
}
|
||||
mergeConfiguredOptInProviderModels({ config: cfg, models });
|
||||
applySyntheticCatalogFallbacks(models);
|
||||
const supplemental = await augmentModelCatalogWithProviderPlugins({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
context: {
|
||||
config: cfg,
|
||||
agentDir,
|
||||
env: process.env,
|
||||
entries: [...models],
|
||||
},
|
||||
});
|
||||
if (supplemental.length > 0) {
|
||||
const seen = new Set(
|
||||
models.map(
|
||||
(entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`,
|
||||
),
|
||||
);
|
||||
for (const entry of supplemental) {
|
||||
const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
models.push(entry);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
// If we found nothing, don't cache this result so we can try again.
|
||||
|
||||
@@ -4,83 +4,18 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
|
||||
import { normalizeModelCompat } from "./model-compat.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
const OPENAI_GPT_54_MODEL_ID = "gpt-5.4";
|
||||
const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
|
||||
const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000;
|
||||
const OPENAI_GPT_54_MAX_TOKENS = 128_000;
|
||||
const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const;
|
||||
const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const;
|
||||
|
||||
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
|
||||
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
|
||||
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
|
||||
const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
|
||||
const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const;
|
||||
|
||||
const ZAI_GLM5_MODEL_ID = "glm-5";
|
||||
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
|
||||
|
||||
// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not yet in pi-ai's built-in
|
||||
// google-gemini-cli catalog. Clone the gemini-3-pro/flash-preview template so users
|
||||
// don't get "Unknown model" errors when Google releases a new minor version.
|
||||
// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai
|
||||
// Google catalogs yet. Clone the nearest gemini-3 template so users don't get
|
||||
// "Unknown model" errors when Google ships new minor-version models before pi-ai
|
||||
// updates its built-in registry.
|
||||
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
|
||||
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
|
||||
const GEMINI_3_1_PRO_TEMPLATE_IDS = ["gemini-3-pro-preview"] as const;
|
||||
const GEMINI_3_1_FLASH_TEMPLATE_IDS = ["gemini-3-flash-preview"] as const;
|
||||
|
||||
function resolveOpenAIGpt54ForwardCompatModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
modelRegistry: ModelRegistry,
|
||||
): Model<Api> | undefined {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (normalizedProvider !== "openai") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmedModelId = modelId.trim();
|
||||
const lower = trimmedModelId.toLowerCase();
|
||||
let templateIds: readonly string[];
|
||||
if (lower === OPENAI_GPT_54_MODEL_ID) {
|
||||
templateIds = OPENAI_GPT_54_TEMPLATE_MODEL_IDS;
|
||||
} else if (lower === OPENAI_GPT_54_PRO_MODEL_ID) {
|
||||
templateIds = OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
cloneFirstTemplateModel({
|
||||
normalizedProvider,
|
||||
trimmedModelId,
|
||||
templateIds: [...templateIds],
|
||||
modelRegistry,
|
||||
patch: {
|
||||
api: "openai-responses",
|
||||
provider: normalizedProvider,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS,
|
||||
maxTokens: OPENAI_GPT_54_MAX_TOKENS,
|
||||
},
|
||||
}) ??
|
||||
normalizeModelCompat({
|
||||
id: trimmedModelId,
|
||||
name: trimmedModelId,
|
||||
api: "openai-responses",
|
||||
provider: normalizedProvider,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: OPENAI_GPT_54_CONTEXT_TOKENS,
|
||||
maxTokens: OPENAI_GPT_54_MAX_TOKENS,
|
||||
} as Model<Api>)
|
||||
);
|
||||
}
|
||||
|
||||
function cloneFirstTemplateModel(params: {
|
||||
normalizedProvider: string;
|
||||
trimmedModelId: string;
|
||||
@@ -104,88 +39,6 @@ function cloneFirstTemplateModel(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAnthropic46ForwardCompatModel(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: ModelRegistry;
|
||||
dashModelId: string;
|
||||
dotModelId: string;
|
||||
dashTemplateId: string;
|
||||
dotTemplateId: string;
|
||||
fallbackTemplateIds: readonly string[];
|
||||
}): Model<Api> | undefined {
|
||||
const { provider, modelId, modelRegistry, dashModelId, dotModelId } = params;
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
if (normalizedProvider !== "anthropic") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmedModelId = modelId.trim();
|
||||
const lower = trimmedModelId.toLowerCase();
|
||||
const is46Model =
|
||||
lower === dashModelId ||
|
||||
lower === dotModelId ||
|
||||
lower.startsWith(`${dashModelId}-`) ||
|
||||
lower.startsWith(`${dotModelId}-`);
|
||||
if (!is46Model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const templateIds: string[] = [];
|
||||
if (lower.startsWith(dashModelId)) {
|
||||
templateIds.push(lower.replace(dashModelId, params.dashTemplateId));
|
||||
}
|
||||
if (lower.startsWith(dotModelId)) {
|
||||
templateIds.push(lower.replace(dotModelId, params.dotTemplateId));
|
||||
}
|
||||
templateIds.push(...params.fallbackTemplateIds);
|
||||
|
||||
return cloneFirstTemplateModel({
|
||||
normalizedProvider,
|
||||
trimmedModelId,
|
||||
templateIds,
|
||||
modelRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAnthropicOpus46ForwardCompatModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
modelRegistry: ModelRegistry,
|
||||
): Model<Api> | undefined {
|
||||
return resolveAnthropic46ForwardCompatModel({
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
dashModelId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
dashTemplateId: "claude-opus-4-5",
|
||||
dotTemplateId: "claude-opus-4.5",
|
||||
fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAnthropicSonnet46ForwardCompatModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
modelRegistry: ModelRegistry,
|
||||
): Model<Api> | undefined {
|
||||
return resolveAnthropic46ForwardCompatModel({
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
dashModelId: ANTHROPIC_SONNET_46_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID,
|
||||
dashTemplateId: "claude-sonnet-4-5",
|
||||
dotTemplateId: "claude-sonnet-4.5",
|
||||
fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS,
|
||||
});
|
||||
}
|
||||
|
||||
// gemini-3.1-pro-preview / gemini-3.1-flash-preview are not present in some pi-ai
|
||||
// Google catalogs yet. Clone the nearest gemini-3 template so users don't get
|
||||
// "Unknown model" errors when Google ships new minor-version models before pi-ai
|
||||
// updates its built-in registry.
|
||||
function resolveGoogle31ForwardCompatModel(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
@@ -264,9 +117,6 @@ export function resolveForwardCompatModel(
|
||||
modelRegistry: ModelRegistry,
|
||||
): Model<Api> | undefined {
|
||||
return (
|
||||
resolveOpenAIGpt54ForwardCompatModel(provider, modelId, modelRegistry) ??
|
||||
resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ??
|
||||
resolveAnthropicSonnet46ForwardCompatModel(provider, modelId, modelRegistry) ??
|
||||
resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ??
|
||||
resolveGoogle31ForwardCompatModel(provider, modelId, modelRegistry)
|
||||
);
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { resolveProviderBuiltInModelSuppression } from "../plugins/provider-runtime.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
|
||||
const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
|
||||
function resolveBuiltInModelSuppression(params: { provider?: string | null; id?: string | null }) {
|
||||
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "");
|
||||
const modelId = params.id?.trim().toLowerCase() ?? "";
|
||||
if (!provider || !modelId) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveProviderBuiltInModelSuppression({
|
||||
env: process.env,
|
||||
context: {
|
||||
env: process.env,
|
||||
provider,
|
||||
modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldSuppressBuiltInModel(params: {
|
||||
provider?: string | null;
|
||||
id?: string | null;
|
||||
}) {
|
||||
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "");
|
||||
const id = params.id?.trim().toLowerCase() ?? "";
|
||||
|
||||
// pi-ai still ships non-Codex Spark rows, but OpenClaw treats Spark as
|
||||
// Codex-only until upstream availability is proven on direct API paths.
|
||||
return SUPPRESSED_SPARK_PROVIDERS.has(provider) && id === OPENAI_DIRECT_SPARK_MODEL_ID;
|
||||
return resolveBuiltInModelSuppression(params)?.suppress ?? false;
|
||||
}
|
||||
|
||||
export function buildSuppressedBuiltInModelError(params: {
|
||||
provider?: string | null;
|
||||
id?: string | null;
|
||||
}): string | undefined {
|
||||
if (!shouldSuppressBuiltInModel(params)) {
|
||||
return undefined;
|
||||
}
|
||||
const provider = normalizeProviderId(params.provider?.trim().toLowerCase() ?? "") || "openai";
|
||||
return `Unknown model: ${provider}/${OPENAI_DIRECT_SPARK_MODEL_ID}. ${OPENAI_DIRECT_SPARK_MODEL_ID} is only supported via openai-codex OAuth. Use openai-codex/${OPENAI_DIRECT_SPARK_MODEL_ID}.`;
|
||||
return resolveBuiltInModelSuppression(params)?.errorMessage;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ type InlineProviderConfig = {
|
||||
headers?: unknown;
|
||||
};
|
||||
|
||||
const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["anthropic", "google-gemini-cli", "openai", "zai"]);
|
||||
const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]);
|
||||
|
||||
function sanitizeModelHeaders(
|
||||
headers: unknown,
|
||||
|
||||
@@ -4,6 +4,10 @@ export type {
|
||||
ProviderDiscoveryContext,
|
||||
ProviderCatalogContext,
|
||||
ProviderCatalogResult,
|
||||
ProviderAugmentModelCatalogContext,
|
||||
ProviderBuiltInModelSuppressionContext,
|
||||
ProviderBuiltInModelSuppressionResult,
|
||||
ProviderBuildMissingAuthMessageContext,
|
||||
ProviderCacheTtlEligibilityContext,
|
||||
ProviderFetchUsageSnapshotContext,
|
||||
ProviderPreparedRuntimeAuth,
|
||||
|
||||
@@ -109,6 +109,10 @@ export type {
|
||||
PluginLogger,
|
||||
ProviderAuthContext,
|
||||
ProviderAuthResult,
|
||||
ProviderAugmentModelCatalogContext,
|
||||
ProviderBuiltInModelSuppressionContext,
|
||||
ProviderBuiltInModelSuppressionResult,
|
||||
ProviderBuildMissingAuthMessageContext,
|
||||
ProviderCacheTtlEligibilityContext,
|
||||
ProviderFetchUsageSnapshotContext,
|
||||
ProviderPreparedRuntimeAuth,
|
||||
|
||||
@@ -77,6 +77,22 @@ describe("normalizePluginsConfig", () => {
|
||||
});
|
||||
expect(result.entries["voice-call"]?.hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes legacy plugin ids to their merged bundled plugin id", () => {
|
||||
const result = normalizePluginsConfig({
|
||||
allow: ["openai-codex"],
|
||||
deny: ["openai-codex"],
|
||||
entries: {
|
||||
"openai-codex": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.allow).toEqual(["openai"]);
|
||||
expect(result.deny).toEqual(["openai"]);
|
||||
expect(result.entries.openai?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveEffectiveEnableState", () => {
|
||||
|
||||
@@ -40,7 +40,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
|
||||
"nvidia",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-codex",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"openrouter",
|
||||
@@ -59,11 +58,22 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
|
||||
"zai",
|
||||
]);
|
||||
|
||||
const PLUGIN_ID_ALIASES: Readonly<Record<string, string>> = {
|
||||
"openai-codex": "openai",
|
||||
};
|
||||
|
||||
function normalizePluginId(id: string): string {
|
||||
const trimmed = id.trim();
|
||||
return PLUGIN_ID_ALIASES[trimmed] ?? trimmed;
|
||||
}
|
||||
|
||||
const normalizeList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
return value
|
||||
.map((entry) => (typeof entry === "string" ? normalizePluginId(entry) : ""))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const normalizeSlotValue = (value: unknown): string | null | undefined => {
|
||||
@@ -86,11 +96,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr
|
||||
}
|
||||
const normalized: NormalizedPluginsConfig["entries"] = {};
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!key.trim()) {
|
||||
const normalizedKey = normalizePluginId(key);
|
||||
if (!normalizedKey) {
|
||||
continue;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
normalized[key] = {};
|
||||
normalized[normalizedKey] = {};
|
||||
continue;
|
||||
}
|
||||
const entry = value as Record<string, unknown>;
|
||||
@@ -108,10 +119,12 @@ const normalizePluginEntries = (entries: unknown): NormalizedPluginsConfig["entr
|
||||
allowPromptInjection: hooks.allowPromptInjection,
|
||||
}
|
||||
: undefined;
|
||||
normalized[key] = {
|
||||
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
|
||||
hooks: normalizedHooks,
|
||||
config: "config" in entry ? entry.config : undefined,
|
||||
normalized[normalizedKey] = {
|
||||
...normalized[normalizedKey],
|
||||
enabled:
|
||||
typeof entry.enabled === "boolean" ? entry.enabled : normalized[normalizedKey]?.enabled,
|
||||
hooks: normalizedHooks ?? normalized[normalizedKey]?.hooks,
|
||||
config: "config" in entry ? entry.config : normalized[normalizedKey]?.config,
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
|
||||
@@ -8,8 +8,11 @@ vi.mock("./providers.js", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
buildProviderMissingAuthMessageWithPlugin,
|
||||
prepareProviderExtraParams,
|
||||
resolveProviderCacheTtlEligibility,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
resolveProviderUsageSnapshotWithPlugin,
|
||||
resolveProviderCapabilitiesWithPlugin,
|
||||
resolveProviderUsageAuthWithPlugin,
|
||||
@@ -57,6 +60,7 @@ describe("provider-runtime", () => {
|
||||
expect.objectContaining({
|
||||
provider: "Open Router",
|
||||
bundledProviderAllowlistCompat: true,
|
||||
bundledProviderVitestCompat: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -77,31 +81,59 @@ describe("provider-runtime", () => {
|
||||
displayName: "Demo",
|
||||
windows: [{ label: "Day", usedPercent: 25 }],
|
||||
}));
|
||||
resolvePluginProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
resolveDynamicModel: () => MODEL,
|
||||
prepareDynamicModel,
|
||||
capabilities: {
|
||||
providerFamily: "openai",
|
||||
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
|
||||
if (params?.onlyPluginIds?.includes("openai")) {
|
||||
return [
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
auth: [],
|
||||
buildMissingAuthMessage: () =>
|
||||
'No API key found for provider "openai". Use openai-codex/gpt-5.4.',
|
||||
suppressBuiltInModel: ({ provider, modelId }) =>
|
||||
provider === "azure-openai-responses" && modelId === "gpt-5.3-codex-spark"
|
||||
? { suppress: true, errorMessage: "openai-codex/gpt-5.3-codex-spark" }
|
||||
: undefined,
|
||||
augmentModelCatalog: () => [
|
||||
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
|
||||
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
|
||||
{ provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" },
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex-spark",
|
||||
name: "gpt-5.3-codex-spark",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "demo",
|
||||
label: "Demo",
|
||||
auth: [],
|
||||
resolveDynamicModel: () => MODEL,
|
||||
prepareDynamicModel,
|
||||
capabilities: {
|
||||
providerFamily: "openai",
|
||||
},
|
||||
prepareExtraParams: ({ extraParams }) => ({
|
||||
...extraParams,
|
||||
transport: "auto",
|
||||
}),
|
||||
wrapStreamFn: ({ streamFn }) => streamFn,
|
||||
normalizeResolvedModel: ({ model }) => ({
|
||||
...model,
|
||||
api: "openai-codex-responses",
|
||||
}),
|
||||
prepareRuntimeAuth,
|
||||
resolveUsageAuth,
|
||||
fetchUsageSnapshot,
|
||||
isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"),
|
||||
},
|
||||
prepareExtraParams: ({ extraParams }) => ({
|
||||
...extraParams,
|
||||
transport: "auto",
|
||||
}),
|
||||
wrapStreamFn: ({ streamFn }) => streamFn,
|
||||
normalizeResolvedModel: ({ model }) => ({
|
||||
...model,
|
||||
api: "openai-codex-responses",
|
||||
}),
|
||||
prepareRuntimeAuth,
|
||||
resolveUsageAuth,
|
||||
fetchUsageSnapshot,
|
||||
isCacheTtlEligible: ({ modelId }) => modelId.startsWith("anthropic/"),
|
||||
},
|
||||
]);
|
||||
];
|
||||
});
|
||||
|
||||
expect(
|
||||
runProviderDynamicModel({
|
||||
@@ -234,6 +266,60 @@ describe("provider-runtime", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
buildProviderMissingAuthMessageWithPlugin({
|
||||
provider: "openai",
|
||||
env: process.env,
|
||||
context: {
|
||||
env: process.env,
|
||||
provider: "openai",
|
||||
listProfileIds: (providerId) => (providerId === "openai-codex" ? ["p1"] : []),
|
||||
},
|
||||
}),
|
||||
).toContain("openai-codex/gpt-5.4");
|
||||
|
||||
expect(
|
||||
resolveProviderBuiltInModelSuppression({
|
||||
env: process.env,
|
||||
context: {
|
||||
env: process.env,
|
||||
provider: "azure-openai-responses",
|
||||
modelId: "gpt-5.3-codex-spark",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
suppress: true,
|
||||
errorMessage: expect.stringContaining("openai-codex/gpt-5.3-codex-spark"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
augmentModelCatalogWithProviderPlugins({
|
||||
env: process.env,
|
||||
context: {
|
||||
env: process.env,
|
||||
entries: [
|
||||
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ provider: "openai", id: "gpt-5.2-pro", name: "GPT-5.2 Pro" },
|
||||
{ provider: "openai-codex", id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ provider: "openai", id: "gpt-5.4", name: "gpt-5.4" },
|
||||
{ provider: "openai", id: "gpt-5.4-pro", name: "gpt-5.4-pro" },
|
||||
{ provider: "openai-codex", id: "gpt-5.4", name: "gpt-5.4" },
|
||||
{
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex-spark",
|
||||
name: "gpt-5.3-codex-spark",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
onlyPluginIds: ["openai"],
|
||||
}),
|
||||
);
|
||||
expect(prepareDynamicModel).toHaveBeenCalledTimes(1);
|
||||
expect(prepareRuntimeAuth).toHaveBeenCalledTimes(1);
|
||||
expect(resolveUsageAuth).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -2,6 +2,9 @@ import { normalizeProviderId } from "../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolvePluginProviders } from "./providers.js";
|
||||
import type {
|
||||
ProviderAugmentModelCatalogContext,
|
||||
ProviderBuildMissingAuthMessageContext,
|
||||
ProviderBuiltInModelSuppressionContext,
|
||||
ProviderCacheTtlEligibilityContext,
|
||||
ProviderFetchUsageSnapshotContext,
|
||||
ProviderPrepareExtraParamsContext,
|
||||
@@ -25,16 +28,41 @@ function matchesProviderId(provider: ProviderPlugin, providerId: string): boolea
|
||||
return (provider.aliases ?? []).some((alias) => normalizeProviderId(alias) === normalized);
|
||||
}
|
||||
|
||||
function resolveProviderPluginsForHooks(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
onlyPluginIds?: string[];
|
||||
}): ProviderPlugin[] {
|
||||
return resolvePluginProviders({
|
||||
...params,
|
||||
bundledProviderAllowlistCompat: true,
|
||||
bundledProviderVitestCompat: true,
|
||||
});
|
||||
}
|
||||
|
||||
const GLOBAL_PROVIDER_HOOK_PLUGIN_IDS = ["openai"] as const;
|
||||
|
||||
function resolveGlobalProviderHookPlugins(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ProviderPlugin[] {
|
||||
return resolveProviderPluginsForHooks({
|
||||
...params,
|
||||
onlyPluginIds: [...GLOBAL_PROVIDER_HOOK_PLUGIN_IDS],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveProviderRuntimePlugin(params: {
|
||||
provider: string;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ProviderPlugin | undefined {
|
||||
return resolvePluginProviders({
|
||||
...params,
|
||||
bundledProviderAllowlistCompat: true,
|
||||
}).find((plugin) => matchesProviderId(plugin, params.provider));
|
||||
return resolveProviderPluginsForHooks(params).find((plugin) =>
|
||||
matchesProviderId(plugin, params.provider),
|
||||
);
|
||||
}
|
||||
|
||||
export function runProviderDynamicModel(params: {
|
||||
@@ -144,3 +172,48 @@ export function resolveProviderCacheTtlEligibility(params: {
|
||||
}) {
|
||||
return resolveProviderRuntimePlugin(params)?.isCacheTtlEligible?.(params.context);
|
||||
}
|
||||
|
||||
export function buildProviderMissingAuthMessageWithPlugin(params: {
|
||||
provider: string;
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
context: ProviderBuildMissingAuthMessageContext;
|
||||
}) {
|
||||
const plugin = resolveGlobalProviderHookPlugins(params).find((providerPlugin) =>
|
||||
matchesProviderId(providerPlugin, params.provider),
|
||||
);
|
||||
return plugin?.buildMissingAuthMessage?.(params.context) ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderBuiltInModelSuppression(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
context: ProviderBuiltInModelSuppressionContext;
|
||||
}) {
|
||||
for (const plugin of resolveGlobalProviderHookPlugins(params)) {
|
||||
const result = plugin.suppressBuiltInModel?.(params.context);
|
||||
if (result?.suppress) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function augmentModelCatalogWithProviderPlugins(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
context: ProviderAugmentModelCatalogContext;
|
||||
}) {
|
||||
const supplemental = [] as ProviderAugmentModelCatalogContext["entries"];
|
||||
for (const plugin of resolveGlobalProviderHookPlugins(params)) {
|
||||
const next = await plugin.augmentModelCatalog?.(params.context);
|
||||
if (!next || next.length === 0) {
|
||||
continue;
|
||||
}
|
||||
supplemental.push(...next);
|
||||
}
|
||||
return supplemental;
|
||||
}
|
||||
|
||||
@@ -52,4 +52,22 @@ describe("resolvePluginProviders", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can enable bundled provider plugins under Vitest when no explicit plugin config exists", () => {
|
||||
resolvePluginProviders({
|
||||
env: { VITEST: "1" } as NodeJS.ProcessEnv,
|
||||
bundledProviderVitestCompat: true,
|
||||
});
|
||||
|
||||
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
plugins: expect.objectContaining({
|
||||
enabled: true,
|
||||
allow: expect.arrayContaining(["openai", "moonshot", "zai"]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
|
||||
"nvidia",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-codex",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"openrouter",
|
||||
@@ -39,6 +38,32 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
|
||||
"zai",
|
||||
] as const;
|
||||
|
||||
function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean {
|
||||
const plugins = config?.plugins;
|
||||
if (!plugins) {
|
||||
return false;
|
||||
}
|
||||
if (typeof plugins.enabled === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.allow) && plugins.allow.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.deny) && plugins.deny.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (plugins.entries && Object.keys(plugins.entries).length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (plugins.slots && Object.keys(plugins.slots).length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function withBundledProviderAllowlistCompat(
|
||||
config: PluginLoadOptions["config"],
|
||||
): PluginLoadOptions["config"] {
|
||||
@@ -71,20 +96,52 @@ function withBundledProviderAllowlistCompat(
|
||||
};
|
||||
}
|
||||
|
||||
function withBundledProviderVitestCompat(params: {
|
||||
config: PluginLoadOptions["config"];
|
||||
env?: PluginLoadOptions["env"];
|
||||
}): PluginLoadOptions["config"] {
|
||||
const env = params.env ?? process.env;
|
||||
if (!env.VITEST || hasExplicitPluginConfig(params.config)) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
return {
|
||||
...params.config,
|
||||
plugins: {
|
||||
...params.config?.plugins,
|
||||
enabled: true,
|
||||
allow: [...BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS],
|
||||
slots: {
|
||||
...params.config?.plugins?.slots,
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginProviders(params: {
|
||||
config?: PluginLoadOptions["config"];
|
||||
workspaceDir?: string;
|
||||
/** Use an explicit env when plugin roots should resolve independently from process.env. */
|
||||
env?: PluginLoadOptions["env"];
|
||||
bundledProviderAllowlistCompat?: boolean;
|
||||
bundledProviderVitestCompat?: boolean;
|
||||
onlyPluginIds?: string[];
|
||||
}): ProviderPlugin[] {
|
||||
const config = params.bundledProviderAllowlistCompat
|
||||
const maybeAllowlistCompat = params.bundledProviderAllowlistCompat
|
||||
? withBundledProviderAllowlistCompat(params.config)
|
||||
: params.config;
|
||||
const config = params.bundledProviderVitestCompat
|
||||
? withBundledProviderVitestCompat({
|
||||
config: maybeAllowlistCompat,
|
||||
env: params.env,
|
||||
})
|
||||
: maybeAllowlistCompat;
|
||||
const registry = loadOpenClawPlugins({
|
||||
config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
onlyPluginIds: params.onlyPluginIds,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AuthProfileCredential,
|
||||
OAuthCredential,
|
||||
} from "../agents/auth-profiles/types.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import type { ProviderCapabilities } from "../agents/provider-capabilities.js";
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ThinkLevel } from "../auto-reply/thinking.js";
|
||||
@@ -390,6 +391,59 @@ export type ProviderCacheTtlEligibilityContext = {
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-owned missing-auth message override.
|
||||
*
|
||||
* Runs only after OpenClaw exhausts normal env/profile/config auth resolution
|
||||
* for the requested provider. Return a custom message to replace the generic
|
||||
* "No API key found" error.
|
||||
*/
|
||||
export type ProviderBuildMissingAuthMessageContext = {
|
||||
config?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
provider: string;
|
||||
listProfileIds: (providerId: string) => string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in model suppression hook.
|
||||
*
|
||||
* Use this when a provider/plugin needs to hide stale upstream catalog rows or
|
||||
* replace them with a vendor-specific hint. This hook is consulted by model
|
||||
* resolution, model listing, and catalog loading.
|
||||
*/
|
||||
export type ProviderBuiltInModelSuppressionContext = {
|
||||
config?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type ProviderBuiltInModelSuppressionResult = {
|
||||
suppress: boolean;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Final catalog augmentation hook.
|
||||
*
|
||||
* Runs after OpenClaw loads the discovered model catalog and merges configured
|
||||
* opt-in providers. Use this for forward-compat rows or vendor-owned synthetic
|
||||
* entries that should appear in `models list` and model pickers even when the
|
||||
* upstream registry has not caught up yet.
|
||||
*/
|
||||
export type ProviderAugmentModelCatalogContext = {
|
||||
config?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
entries: ModelCatalogEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use ProviderCatalogOrder.
|
||||
*/
|
||||
@@ -560,6 +614,40 @@ export type ProviderPlugin = {
|
||||
* only a subset of upstream models.
|
||||
*/
|
||||
isCacheTtlEligible?: (ctx: ProviderCacheTtlEligibilityContext) => boolean | undefined;
|
||||
/**
|
||||
* Provider-owned missing-auth message override.
|
||||
*
|
||||
* Return a custom message when the provider wants a more specific recovery
|
||||
* hint than OpenClaw's generic auth-store guidance.
|
||||
*/
|
||||
buildMissingAuthMessage?: (
|
||||
ctx: ProviderBuildMissingAuthMessageContext,
|
||||
) => string | null | undefined;
|
||||
/**
|
||||
* Provider-owned built-in model suppression.
|
||||
*
|
||||
* Return `{ suppress: true }` to hide a stale upstream row. Include
|
||||
* `errorMessage` when OpenClaw should surface a provider-specific hint for
|
||||
* direct model resolution failures.
|
||||
*/
|
||||
suppressBuiltInModel?: (
|
||||
ctx: ProviderBuiltInModelSuppressionContext,
|
||||
) => ProviderBuiltInModelSuppressionResult | null | undefined;
|
||||
/**
|
||||
* Provider-owned final catalog augmentation.
|
||||
*
|
||||
* Return extra rows to append to the final catalog after discovery/config
|
||||
* merging. OpenClaw deduplicates by `provider/id`, so plugins only need to
|
||||
* describe the desired supplemental rows.
|
||||
*/
|
||||
augmentModelCatalog?: (
|
||||
ctx: ProviderAugmentModelCatalogContext,
|
||||
) =>
|
||||
| Array<ModelCatalogEntry>
|
||||
| ReadonlyArray<ModelCatalogEntry>
|
||||
| Promise<Array<ModelCatalogEntry> | ReadonlyArray<ModelCatalogEntry> | null | undefined>
|
||||
| null
|
||||
| undefined;
|
||||
wizard?: ProviderPluginWizard;
|
||||
formatApiKey?: (cred: AuthProfileCredential) => string;
|
||||
refreshOAuth?: (cred: OAuthCredential) => Promise<OAuthCredential>;
|
||||
|
||||
Reference in New Issue
Block a user