feat(plugins): move bundled providers behind plugin hooks

This commit is contained in:
Peter Steinberger 2026-03-15 16:57:24 -07:00
parent e7555724af
commit 8e2a1d0941
No known key found for this signature in database
27 changed files with 728 additions and 67 deletions

View File

@ -8,6 +8,7 @@ import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles
import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { coerceSecretRef } from "../../src/config/types.secrets.js";
import { fetchCopilotUsage } from "../../src/infra/provider-usage.fetch.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
@ -130,6 +131,9 @@ const githubCopilotPlugin = {
expiresAt: token.expiresAt,
};
},
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
fetchUsageSnapshot: async (ctx) =>
await fetchCopilotUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
});
},
};

View File

@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import {
createProviderUsageFetch,
makeResponse,
} from "../../src/test-utils/provider-usage-fetch.js";
import geminiCliPlugin from "./index.js";
function registerProvider(): ProviderPlugin {
let provider: ProviderPlugin | undefined;
geminiCliPlugin.register({
registerProvider(nextProvider: ProviderPlugin) {
provider = nextProvider;
},
} as never);
if (!provider) {
throw new Error("provider registration missing");
}
return provider;
}
describe("google-gemini-cli-auth plugin", () => {
it("owns gemini 3.1 forward-compat resolution", () => {
const provider = registerProvider();
const model = provider.resolveDynamicModel?.({
provider: "google-gemini-cli",
modelId: "gemini-3.1-pro-preview",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "gemini-3-pro-preview"
? {
id,
name: id,
api: "google-gemini-cli",
provider: "google-gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_048_576,
maxTokens: 65_536,
}
: null,
} as never,
});
expect(model).toMatchObject({
id: "gemini-3.1-pro-preview",
provider: "google-gemini-cli",
reasoning: true,
});
});
it("owns usage-token parsing", async () => {
const provider = registerProvider();
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "google-gemini-cli",
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken: async () => ({
token: '{"token":"google-oauth-token"}',
accountId: "google-account",
}),
}),
).resolves.toEqual({
token: "google-oauth-token",
accountId: "google-account",
});
});
it("owns usage snapshot fetching", async () => {
const provider = registerProvider();
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
return makeResponse(200, {
buckets: [
{ modelId: "gemini-3.1-pro-preview", remainingFraction: 0.4 },
{ modelId: "gemini-3.1-flash-preview", remainingFraction: 0.8 },
],
});
}
return makeResponse(404, "not found");
});
const snapshot = await provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "google-gemini-cli",
token: "google-oauth-token",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(snapshot).toMatchObject({
provider: "google-gemini-cli",
displayName: "Gemini",
});
expect(snapshot?.windows[0]).toEqual({ label: "Pro", usedPercent: 60 });
expect(snapshot?.windows[1]?.label).toBe("Flash");
expect(snapshot?.windows[1]?.usedPercent).toBeCloseTo(20);
});
});

View File

@ -1,14 +1,23 @@
import {
buildOauthProviderAuthResult,
emptyPluginConfigSchema,
type ProviderFetchUsageSnapshotContext,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/google-gemini-cli-auth";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js";
import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli";
const PROVIDER_LABEL = "Gemini CLI OAuth";
const DEFAULT_MODEL = "google-gemini-cli/gemini-3.1-pro-preview";
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;
const ENV_VARS = [
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
@ -16,6 +25,68 @@ const ENV_VARS = [
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
function cloneFirstTemplateModel(params: {
modelId: string;
templateIds: readonly string[];
ctx: ProviderResolveDynamicModelContext;
}): ProviderRuntimeModel | undefined {
const trimmedModelId = params.modelId.trim();
for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) {
const template = params.ctx.modelRegistry.find(
PROVIDER_ID,
templateId,
) as ProviderRuntimeModel | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
reasoning: true,
} as ProviderRuntimeModel);
}
return undefined;
}
function parseGoogleUsageToken(apiKey: string): string {
try {
const parsed = JSON.parse(apiKey) as { token?: unknown };
if (typeof parsed?.token === "string") {
return parsed.token;
}
} catch {
// ignore
}
return apiKey;
}
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
function resolveGeminiCliForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
const trimmed = ctx.modelId.trim();
const lower = trimmed.toLowerCase();
let templateIds: readonly string[];
if (lower.startsWith(GEMINI_3_1_PRO_PREFIX)) {
templateIds = GEMINI_3_1_PRO_TEMPLATE_IDS;
} else if (lower.startsWith(GEMINI_3_1_FLASH_PREFIX)) {
templateIds = GEMINI_3_1_FLASH_TEMPLATE_IDS;
} else {
return undefined;
}
return cloneFirstTemplateModel({
modelId: trimmed,
templateIds,
ctx,
});
}
const geminiCliPlugin = {
id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth",
@ -68,6 +139,18 @@ const geminiCliPlugin = {
},
},
],
resolveDynamicModel: (ctx) => resolveGeminiCliForwardCompatModel(ctx),
resolveUsageAuth: async (ctx) => {
const auth = await ctx.resolveOAuthToken();
if (!auth) {
return null;
}
return {
...auth,
token: parseGoogleUsageToken(auth.token),
};
},
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
});
},
};

View File

@ -1,5 +1,6 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { buildMinimaxProvider } from "../../src/agents/models-config.providers.static.js";
import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js";
const PROVIDER_ID = "minimax";
@ -30,6 +31,14 @@ const minimaxPlugin = {
};
},
},
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
envDirect: [ctx.env.MINIMAX_CODE_PLAN_KEY, ctx.env.MINIMAX_API_KEY],
});
return apiKey ? { token: apiKey } : null;
},
fetchUsageSnapshot: async (ctx) =>
await fetchMinimaxUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
});
},
};

View File

@ -0,0 +1,33 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
const PROVIDER_ID = "mistral";
const mistralPlugin = {
id: PROVIDER_ID,
name: "Mistral Provider",
description: "Bundled Mistral provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: "Mistral",
docsPath: "/providers/models",
envVars: ["MISTRAL_API_KEY"],
auth: [],
capabilities: {
transcriptToolCallIdMode: "strict9",
transcriptToolCallIdModelHints: [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
],
},
});
},
};
export default mistralPlugin;

View File

@ -0,0 +1,9 @@
{
"id": "mistral",
"providers": ["mistral"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/mistral-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Mistral provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import {
createProviderUsageFetch,
makeResponse,
} from "../../src/test-utils/provider-usage-fetch.js";
import openAICodexPlugin from "./index.js";
function registerProvider(): ProviderPlugin {
@ -62,4 +66,36 @@ describe("openai-codex plugin", () => {
transport: "auto",
});
});
it("owns usage snapshot fetching", async () => {
const provider = registerProvider();
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("chatgpt.com/backend-api/wham/usage")) {
return makeResponse(200, {
rate_limit: {
primary_window: { used_percent: 12, limit_window_seconds: 10800, reset_at: 1_705_000 },
},
plan_type: "Plus",
});
}
return makeResponse(404, "not found");
});
await expect(
provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "openai-codex",
token: "codex-token",
accountId: "acc-1",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
}),
).resolves.toEqual({
provider: "openai-codex",
displayName: "Codex",
windows: [{ label: "3h", usedPercent: 12, resetAt: 1_705_000_000 }],
plan: "Plus",
});
});
});

View File

@ -10,6 +10,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { normalizeProviderId } from "../../src/agents/model-selection.js";
import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js";
import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js";
const PROVIDER_ID = "openai-codex";
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
@ -182,6 +183,9 @@ const openAICodexPlugin = {
}
return normalizeCodexTransport(ctx.model);
},
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
fetchUsageSnapshot: async (ctx) =>
await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn),
});
},
};

View File

@ -0,0 +1,26 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
const PROVIDER_ID = "opencode-go";
const opencodeGoPlugin = {
id: PROVIDER_ID,
name: "OpenCode Go Provider",
description: "Bundled OpenCode Go provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: "OpenCode Go",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
auth: [],
capabilities: {
openAiCompatTurnValidation: false,
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
});
},
};
export default opencodeGoPlugin;

View File

@ -0,0 +1,9 @@
{
"id": "opencode-go",
"providers": ["opencode-go"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/opencode-go-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw OpenCode Go provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,26 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
const PROVIDER_ID = "opencode";
const opencodePlugin = {
id: PROVIDER_ID,
name: "OpenCode Zen Provider",
description: "Bundled OpenCode Zen provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: "OpenCode Zen",
docsPath: "/providers/models",
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
auth: [],
capabilities: {
openAiCompatTurnValidation: false,
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
});
},
};
export default opencodePlugin;

View File

@ -0,0 +1,9 @@
{
"id": "opencode",
"providers": ["opencode"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/opencode-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw OpenCode Zen provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -1,5 +1,6 @@
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { buildXiaomiProvider } from "../../src/agents/models-config.providers.static.js";
import { PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js";
const PROVIDER_ID = "xiaomi";
@ -30,6 +31,17 @@ const xiaomiPlugin = {
};
},
},
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
envDirect: [ctx.env.XIAOMI_API_KEY],
});
return apiKey ? { token: apiKey } : null;
},
fetchUsageSnapshot: async () => ({
provider: "xiaomi",
displayName: PROVIDER_LABELS.xiaomi,
windows: [],
}),
});
},
};

View File

@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import {
createProviderUsageFetch,
makeResponse,
} from "../../src/test-utils/provider-usage-fetch.js";
import zaiPlugin from "./index.js";
function registerProvider(): ProviderPlugin {
let provider: ProviderPlugin | undefined;
zaiPlugin.register({
registerProvider(nextProvider: ProviderPlugin) {
provider = nextProvider;
},
} as never);
if (!provider) {
throw new Error("provider registration missing");
}
return provider;
}
describe("zai plugin", () => {
it("owns glm-5 forward-compat resolution", () => {
const provider = registerProvider();
const model = provider.resolveDynamicModel?.({
provider: "zai",
modelId: "glm-5",
modelRegistry: {
find: (_provider: string, id: string) =>
id === "glm-4.7"
? {
id,
name: id,
api: "openai-completions",
provider: "zai",
baseUrl: "https://api.z.ai/api/paas/v4",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 202_752,
maxTokens: 16_384,
}
: null,
} as never,
});
expect(model).toMatchObject({
id: "glm-5",
provider: "zai",
api: "openai-completions",
reasoning: true,
});
});
it("owns usage auth resolution", async () => {
const provider = registerProvider();
await expect(
provider.resolveUsageAuth?.({
config: {} as never,
env: {
ZAI_API_KEY: "env-zai-token",
} as NodeJS.ProcessEnv,
provider: "zai",
resolveApiKeyFromConfigAndStore: () => "env-zai-token",
resolveOAuthToken: async () => null,
}),
).resolves.toEqual({
token: "env-zai-token",
});
});
it("owns usage snapshot fetching", async () => {
const provider = registerProvider();
const mockFetch = createProviderUsageFetch(async (url) => {
if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) {
return makeResponse(200, {
success: true,
code: 200,
data: {
planName: "Pro",
limits: [
{
type: "TOKENS_LIMIT",
percentage: 25,
unit: 3,
number: 6,
nextResetTime: "2026-01-07T06:00:00Z",
},
],
},
});
}
return makeResponse(404, "not found");
});
await expect(
provider.fetchUsageSnapshot?.({
config: {} as never,
env: {} as NodeJS.ProcessEnv,
provider: "zai",
token: "env-zai-token",
timeoutMs: 5_000,
fetchFn: mockFetch as unknown as typeof fetch,
}),
).resolves.toEqual({
provider: "zai",
displayName: "z.ai",
windows: [{ label: "Tokens (6h)", usedPercent: 25, resetAt: 1_767_765_600_000 }],
plan: "Pro",
});
});
});

118
extensions/zai/index.ts Normal file
View File

@ -0,0 +1,118 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { createZaiToolStreamWrapper } from "../../src/agents/pi-embedded-runner/zai-stream-wrappers.js";
import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js";
import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js";
const PROVIDER_ID = "zai";
const GLM5_MODEL_ID = "glm-5";
const GLM5_TEMPLATE_MODEL_ID = "glm-4.7";
function resolveGlm5ForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
const trimmedModelId = ctx.modelId.trim();
const lower = trimmedModelId.toLowerCase();
if (lower !== GLM5_MODEL_ID && !lower.startsWith(`${GLM5_MODEL_ID}-`)) {
return undefined;
}
const template = ctx.modelRegistry.find(
PROVIDER_ID,
GLM5_TEMPLATE_MODEL_ID,
) as ProviderRuntimeModel | null;
if (template) {
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
reasoning: true,
} as ProviderRuntimeModel);
}
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
api: "openai-completions",
provider: PROVIDER_ID,
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as ProviderRuntimeModel);
}
function resolveLegacyZaiUsageToken(env: NodeJS.ProcessEnv): string | undefined {
try {
const authPath = path.join(
resolveRequiredHomeDir(env, os.homedir),
".pi",
"agent",
"auth.json",
);
if (!fs.existsSync(authPath)) {
return undefined;
}
const parsed = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<
string,
{ access?: string }
>;
return parsed["z-ai"]?.access || parsed.zai?.access;
} catch {
return undefined;
}
}
const zaiPlugin = {
id: PROVIDER_ID,
name: "Z.AI Provider",
description: "Bundled Z.AI provider plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerProvider({
id: PROVIDER_ID,
label: "Z.AI",
aliases: ["z-ai", "z.ai"],
docsPath: "/providers/models",
envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"],
auth: [],
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
prepareExtraParams: (ctx) => {
if (ctx.extraParams?.tool_stream !== undefined) {
return ctx.extraParams;
}
return {
...ctx.extraParams,
tool_stream: true,
};
},
wrapStreamFn: (ctx) =>
createZaiToolStreamWrapper(ctx.streamFn, ctx.extraParams?.tool_stream !== false),
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
providerIds: [PROVIDER_ID, "z-ai"],
envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY],
});
if (apiKey) {
return { token: apiKey };
}
const legacyToken = resolveLegacyZaiUsageToken(ctx.env);
return legacyToken ? { token: legacyToken } : null;
},
fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
isCacheTtlEligible: () => true,
});
},
};
export default zaiPlugin;

View File

@ -0,0 +1,9 @@
{
"id": "zai",
"providers": ["zai"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/zai-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw Z.AI provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -33,6 +33,7 @@ import {
resolveOpenAIFastMode,
resolveOpenAIServiceTier,
} from "./openai-stream-wrappers.js";
import { createZaiToolStreamWrapper } from "./zai-stream-wrappers.js";
/**
* Resolve provider-specific extra params from model config.
@ -214,39 +215,6 @@ function createGoogleThinkingPayloadWrapper(
};
}
/**
* Create a streamFn wrapper that injects tool_stream=true for Z.AI providers.
*
* Z.AI's API supports the `tool_stream` parameter to enable real-time streaming
* of tool call arguments and reasoning content. When enabled, the API returns
* progressive tool_call deltas, allowing users to see tool execution in real-time.
*
* @see https://docs.z.ai/api-reference#streaming
*/
function createZaiToolStreamWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (!enabled) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
// Inject tool_stream: true for Z.AI API
(payload as Record<string, unknown>).tool_stream = true;
}
return originalOnPayload?.(payload, model);
},
});
};
}
function resolveAliasedParamValue(
sources: Array<Record<string, unknown> | undefined>,
snakeCaseKey: string,

View File

@ -7,14 +7,12 @@ vi.mock("../pi-model-discovery.js", () => ({
import { buildInlineProviderModels, resolveModel } from "./model.js";
import {
buildOpenAICodexForwardCompatExpectation,
GOOGLE_GEMINI_CLI_FLASH_TEMPLATE_MODEL,
GOOGLE_GEMINI_CLI_PRO_TEMPLATE_MODEL,
makeModel,
mockDiscoveredModel,
mockGoogleGeminiCliFlashTemplateModel,
mockGoogleGeminiCliProTemplateModel,
mockOpenAICodexTemplateModel,
resetMockDiscoverModels,
} from "./model.test-harness.js";
@ -42,32 +40,6 @@ describe("pi embedded model e2e smoke", () => {
]);
});
it("builds an openai-codex forward-compat fallback for gpt-5.3-codex", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.3-codex", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex"));
});
it("builds an openai-codex forward-compat fallback for gpt-5.4", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
});
it("builds an openai-codex forward-compat fallback for gpt-5.3-codex-spark", () => {
mockOpenAICodexTemplateModel();
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject(
buildOpenAICodexForwardCompatExpectation("gpt-5.3-codex-spark"),
);
});
it("keeps unknown-model errors for non-forward-compat IDs", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined();

View File

@ -34,6 +34,8 @@ type InlineProviderConfig = {
headers?: unknown;
};
const PLUGIN_FIRST_DYNAMIC_PROVIDERS = new Set(["google-gemini-cli", "zai"]);
function sanitizeModelHeaders(
headers: unknown,
opts?: { stripSecretRefMarkers?: boolean },
@ -230,6 +232,34 @@ function resolveExplicitModelWithRegistry(params: {
};
}
if (PLUGIN_FIRST_DYNAMIC_PROVIDERS.has(normalizeProviderId(provider))) {
// Give migrated provider plugins first shot at ids that still keep a core
// forward-compat fallback for disabled-plugin/test compatibility.
const pluginDynamicModel = runProviderDynamicModel({
provider,
config: cfg,
context: {
config: cfg,
agentDir,
provider,
modelId,
modelRegistry,
providerConfig,
},
});
if (pluginDynamicModel) {
return {
kind: "resolved",
model: normalizeResolvedModel({
provider,
cfg,
agentDir,
model: pluginDynamicModel,
}),
};
}
}
// Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback.
// Otherwise, configured providers can default to a generic API and break specific transports.
const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry);

View File

@ -0,0 +1,29 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
/**
* Inject `tool_stream=true` for Z.AI requests so tool-call deltas stream in
* real time. Providers can disable this by setting `params.tool_stream=false`.
*/
export function createZaiToolStreamWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (!enabled) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
(payload as Record<string, unknown>).tool_stream = true;
}
return originalOnPayload?.(payload, model);
},
});
};
}

View File

@ -27,7 +27,7 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
dropThinkingBlockModelHints: [],
};
const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
const CORE_PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
anthropic: {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
@ -36,6 +36,12 @@ const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
providerFamily: "anthropic",
dropThinkingBlockModelHints: ["claude"],
},
openai: {
providerFamily: "openai",
},
};
const PLUGIN_CAPABILITIES_FALLBACKS: Record<string, Partial<ProviderCapabilities>> = {
mistral: {
transcriptToolCallIdMode: "strict9",
transcriptToolCallIdModelHints: [
@ -48,9 +54,6 @@ const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
"mistralai",
],
},
openai: {
providerFamily: "openai",
},
opencode: {
openAiCompatTurnValidation: false,
geminiThoughtSignatureSanitization: true,
@ -70,8 +73,8 @@ export function resolveProviderCapabilities(provider?: string | null): ProviderC
: undefined;
return {
...DEFAULT_PROVIDER_CAPABILITIES,
...PROVIDER_CAPABILITIES[normalized],
...pluginCapabilities,
...CORE_PROVIDER_CAPABILITIES[normalized],
...(pluginCapabilities ?? PLUGIN_CAPABILITIES_FALLBACKS[normalized]),
};
}

View File

@ -33,11 +33,14 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"kimi-coding",
"minimax",
"minimax-portal-auth",
"mistral",
"modelstudio",
"moonshot",
"nvidia",
"ollama",
"openai-codex",
"opencode",
"opencode-go",
"openrouter",
"phone-control",
"qianfan",
@ -51,6 +54,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"vllm",
"volcengine",
"xiaomi",
"zai",
]);
const normalizeList = (value: unknown): string[] => {

View File

@ -15,11 +15,14 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"kimi-coding",
"minimax",
"minimax-portal-auth",
"mistral",
"modelstudio",
"moonshot",
"nvidia",
"ollama",
"openai-codex",
"opencode",
"opencode-go",
"openrouter",
"qianfan",
"qwen-portal-auth",
@ -31,6 +34,7 @@ const BUNDLED_PROVIDER_ALLOWLIST_COMPAT_PLUGIN_IDS = [
"volcengine",
"vllm",
"xiaomi",
"zai",
] as const;
function withBundledProviderAllowlistCompat(