feat(plugins): move bundled providers behind plugin hooks
This commit is contained in:
parent
e7555724af
commit
8e2a1d0941
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
104
extensions/google-gemini-cli-auth/index.test.ts
Normal file
104
extensions/google-gemini-cli-auth/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
33
extensions/mistral/index.ts
Normal file
33
extensions/mistral/index.ts
Normal 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;
|
||||
9
extensions/mistral/openclaw.plugin.json
Normal file
9
extensions/mistral/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "mistral",
|
||||
"providers": ["mistral"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/mistral/package.json
Normal file
12
extensions/mistral/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
26
extensions/opencode-go/index.ts
Normal file
26
extensions/opencode-go/index.ts
Normal 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;
|
||||
9
extensions/opencode-go/openclaw.plugin.json
Normal file
9
extensions/opencode-go/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "opencode-go",
|
||||
"providers": ["opencode-go"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/opencode-go/package.json
Normal file
12
extensions/opencode-go/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
26
extensions/opencode/index.ts
Normal file
26
extensions/opencode/index.ts
Normal 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;
|
||||
9
extensions/opencode/openclaw.plugin.json
Normal file
9
extensions/opencode/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "opencode",
|
||||
"providers": ["opencode"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/opencode/package.json
Normal file
12
extensions/opencode/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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: [],
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
112
extensions/zai/index.test.ts
Normal file
112
extensions/zai/index.test.ts
Normal 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
118
extensions/zai/index.ts
Normal 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;
|
||||
9
extensions/zai/openclaw.plugin.json
Normal file
9
extensions/zai/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "zai",
|
||||
"providers": ["zai"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/zai/package.json
Normal file
12
extensions/zai/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
29
src/agents/pi-embedded-runner/zai-stream-wrappers.ts
Normal file
29
src/agents/pi-embedded-runner/zai-stream-wrappers.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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[] => {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user