From 8e2a1d0941c7e93e31dfc69a5914f4130bffc50d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 15 Mar 2026 16:57:24 -0700 Subject: [PATCH] feat(plugins): move bundled providers behind plugin hooks --- extensions/github-copilot/index.ts | 4 + .../google-gemini-cli-auth/index.test.ts | 104 +++++++++++++++ extensions/google-gemini-cli-auth/index.ts | 83 ++++++++++++ extensions/minimax/index.ts | 9 ++ extensions/mistral/index.ts | 33 +++++ extensions/mistral/openclaw.plugin.json | 9 ++ extensions/mistral/package.json | 12 ++ extensions/openai-codex/index.test.ts | 36 ++++++ extensions/openai-codex/index.ts | 4 + extensions/opencode-go/index.ts | 26 ++++ extensions/opencode-go/openclaw.plugin.json | 9 ++ extensions/opencode-go/package.json | 12 ++ extensions/opencode/index.ts | 26 ++++ extensions/opencode/openclaw.plugin.json | 9 ++ extensions/opencode/package.json | 12 ++ extensions/xiaomi/index.ts | 12 ++ extensions/zai/index.test.ts | 112 +++++++++++++++++ extensions/zai/index.ts | 118 ++++++++++++++++++ extensions/zai/openclaw.plugin.json | 9 ++ extensions/zai/package.json | 12 ++ src/agents/pi-embedded-runner/extra-params.ts | 34 +---- .../model.forward-compat.test.ts | 28 ----- src/agents/pi-embedded-runner/model.ts | 30 +++++ .../pi-embedded-runner/zai-stream-wrappers.ts | 29 +++++ src/agents/provider-capabilities.ts | 15 ++- src/plugins/config-state.ts | 4 + src/plugins/providers.ts | 4 + 27 files changed, 728 insertions(+), 67 deletions(-) create mode 100644 extensions/google-gemini-cli-auth/index.test.ts create mode 100644 extensions/mistral/index.ts create mode 100644 extensions/mistral/openclaw.plugin.json create mode 100644 extensions/mistral/package.json create mode 100644 extensions/opencode-go/index.ts create mode 100644 extensions/opencode-go/openclaw.plugin.json create mode 100644 extensions/opencode-go/package.json create mode 100644 extensions/opencode/index.ts create mode 100644 extensions/opencode/openclaw.plugin.json create mode 100644 extensions/opencode/package.json create mode 100644 extensions/zai/index.test.ts create mode 100644 extensions/zai/index.ts create mode 100644 extensions/zai/openclaw.plugin.json create mode 100644 extensions/zai/package.json create mode 100644 src/agents/pi-embedded-runner/zai-stream-wrappers.ts diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index d38e7442d75..19114472830 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -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), }); }, }; diff --git a/extensions/google-gemini-cli-auth/index.test.ts b/extensions/google-gemini-cli-auth/index.test.ts new file mode 100644 index 00000000000..d0542e3473c --- /dev/null +++ b/extensions/google-gemini-cli-auth/index.test.ts @@ -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); + }); +}); diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts index dd84e93ba4e..290cc19598f 100644 --- a/extensions/google-gemini-cli-auth/index.ts +++ b/extensions/google-gemini-cli-auth/index.ts @@ -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), }); }, }; diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index 4076362404f..6585e27d7cf 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -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), }); }, }; diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts new file mode 100644 index 00000000000..355c957282b --- /dev/null +++ b/extensions/mistral/index.ts @@ -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; diff --git a/extensions/mistral/openclaw.plugin.json b/extensions/mistral/openclaw.plugin.json new file mode 100644 index 00000000000..dd38282811b --- /dev/null +++ b/extensions/mistral/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "mistral", + "providers": ["mistral"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/mistral/package.json b/extensions/mistral/package.json new file mode 100644 index 00000000000..29649db38f5 --- /dev/null +++ b/extensions/mistral/package.json @@ -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" + ] + } +} diff --git a/extensions/openai-codex/index.test.ts b/extensions/openai-codex/index.test.ts index 95dd1aa1a73..53bbd700f17 100644 --- a/extensions/openai-codex/index.test.ts +++ b/extensions/openai-codex/index.test.ts @@ -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", + }); + }); }); diff --git a/extensions/openai-codex/index.ts b/extensions/openai-codex/index.ts index 592223f2419..9d8ee0769af 100644 --- a/extensions/openai-codex/index.ts +++ b/extensions/openai-codex/index.ts @@ -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), }); }, }; diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts new file mode 100644 index 00000000000..3740c0190c4 --- /dev/null +++ b/extensions/opencode-go/index.ts @@ -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; diff --git a/extensions/opencode-go/openclaw.plugin.json b/extensions/opencode-go/openclaw.plugin.json new file mode 100644 index 00000000000..09d48bcf314 --- /dev/null +++ b/extensions/opencode-go/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "opencode-go", + "providers": ["opencode-go"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/opencode-go/package.json b/extensions/opencode-go/package.json new file mode 100644 index 00000000000..ab32e55d7dc --- /dev/null +++ b/extensions/opencode-go/package.json @@ -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" + ] + } +} diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts new file mode 100644 index 00000000000..81175fc5613 --- /dev/null +++ b/extensions/opencode/index.ts @@ -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; diff --git a/extensions/opencode/openclaw.plugin.json b/extensions/opencode/openclaw.plugin.json new file mode 100644 index 00000000000..f61e9b99b67 --- /dev/null +++ b/extensions/opencode/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "opencode", + "providers": ["opencode"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/opencode/package.json b/extensions/opencode/package.json new file mode 100644 index 00000000000..a8c185cd94b --- /dev/null +++ b/extensions/opencode/package.json @@ -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" + ] + } +} diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 847d7836ecc..37d7d799691 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -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: [], + }), }); }, }; diff --git a/extensions/zai/index.test.ts b/extensions/zai/index.test.ts new file mode 100644 index 00000000000..119309d31a3 --- /dev/null +++ b/extensions/zai/index.test.ts @@ -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", + }); + }); +}); diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts new file mode 100644 index 00000000000..d9b81b87dda --- /dev/null +++ b/extensions/zai/index.ts @@ -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; diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json new file mode 100644 index 00000000000..5e23160ddb6 --- /dev/null +++ b/extensions/zai/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "zai", + "providers": ["zai"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/zai/package.json b/extensions/zai/package.json new file mode 100644 index 00000000000..10283bbdbdd --- /dev/null +++ b/extensions/zai/package.json @@ -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" + ] + } +} diff --git a/src/agents/pi-embedded-runner/extra-params.ts b/src/agents/pi-embedded-runner/extra-params.ts index 7f329302803..713b193d7e7 100644 --- a/src/agents/pi-embedded-runner/extra-params.ts +++ b/src/agents/pi-embedded-runner/extra-params.ts @@ -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).tool_stream = true; - } - return originalOnPayload?.(payload, model); - }, - }); - }; -} - function resolveAliasedParamValue( sources: Array | undefined>, snakeCaseKey: string, diff --git a/src/agents/pi-embedded-runner/model.forward-compat.test.ts b/src/agents/pi-embedded-runner/model.forward-compat.test.ts index 5def8359c13..f0cdc3e29cb 100644 --- a/src/agents/pi-embedded-runner/model.forward-compat.test.ts +++ b/src/agents/pi-embedded-runner/model.forward-compat.test.ts @@ -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(); diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index 1a36178f9ce..ed6356a361f 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -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); diff --git a/src/agents/pi-embedded-runner/zai-stream-wrappers.ts b/src/agents/pi-embedded-runner/zai-stream-wrappers.ts new file mode 100644 index 00000000000..e6c1077cf5e --- /dev/null +++ b/src/agents/pi-embedded-runner/zai-stream-wrappers.ts @@ -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).tool_stream = true; + } + return originalOnPayload?.(payload, model); + }, + }); + }; +} diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 00a09b2386c..6f6f9fe4c9f 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -27,7 +27,7 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { dropThinkingBlockModelHints: [], }; -const PROVIDER_CAPABILITIES: Record> = { +const CORE_PROVIDER_CAPABILITIES: Record> = { anthropic: { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], @@ -36,6 +36,12 @@ const PROVIDER_CAPABILITIES: Record> = { providerFamily: "anthropic", dropThinkingBlockModelHints: ["claude"], }, + openai: { + providerFamily: "openai", + }, +}; + +const PLUGIN_CAPABILITIES_FALLBACKS: Record> = { mistral: { transcriptToolCallIdMode: "strict9", transcriptToolCallIdModelHints: [ @@ -48,9 +54,6 @@ const PROVIDER_CAPABILITIES: Record> = { "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]), }; } diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 16345b1b986..33fd5d87b3d 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,11 +33,14 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "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([ "vllm", "volcengine", "xiaomi", + "zai", ]); const normalizeList = (value: unknown): string[] => { diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index dda000e2641..fdcd0bb67a9 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -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(