paseo-plugin-zcode-provider 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ import { createHash } from "node:crypto";
2
+ // Types only, from the module every supported Paseo resolves; Paseo 0.8–0.10
3
+ // reject `@getpaseo/plugin/server/usage` even as a type import.
4
+ import type {
5
+ UsageReport,
6
+ UsageSourceRegistration,
7
+ UsageWindow,
8
+ } from "@getpaseo/plugin/server";
9
+ import { z } from "zod";
10
+ import {
11
+ accountSettingsSchema,
12
+ familySchema,
13
+ nativeViewSchema,
14
+ sessionStateSchema,
15
+ type AccountService,
16
+ type NativeView,
17
+ type SettingsHost,
18
+ } from "./account.js";
19
+ import { AdapterError } from "./errors.js";
20
+ import { logger } from "./logger.js";
21
+ import { decodeModel, ZCODE_PROVIDER_ID } from "./mapping.js";
22
+ import type { AccountFamily } from "../shared/account.js";
23
+
24
+ type UsageScope = Parameters<UsageSourceRegistration["discover"]>[0];
25
+ type UsageAccount = Awaited<
26
+ ReturnType<UsageSourceRegistration["discover"]>
27
+ >[number];
28
+
29
+ const inputSchema = z.object({ providerId: z.string().min(1) }).strict();
30
+ type UsageInput = z.output<typeof inputSchema>;
31
+
32
+ const FAMILY_LABELS: Record<AccountFamily, string> = {
33
+ zai: "Z.ai",
34
+ bigmodel: "BigModel",
35
+ };
36
+ const SETTINGS_PATH =
37
+ "Settings → Plugins → zcode-provider → Settings → Account";
38
+
39
+ const limitSchema = z.object({
40
+ type: z.string(),
41
+ unit: z.number().optional(),
42
+ number: z.number().optional(),
43
+ percentage: z.number().optional(),
44
+ nextResetTime: z.number().nullish(),
45
+ });
46
+ type QuotaLimit = z.output<typeof limitSchema>;
47
+ // The fields of ZCode's UsageEntitlementSnapshot that this source reads.
48
+ const snapshotSchema = z.object({
49
+ unavailableReason: z.string().optional(),
50
+ subscription: z
51
+ .object({ details: z.array(z.object({ productName: z.string() })) })
52
+ .nullish(),
53
+ quota: z
54
+ .object({ level: z.string().nullable(), limits: z.array(limitSchema) })
55
+ .nullish(),
56
+ mcpQuota: z.object({ aggregate: limitSchema }).nullish(),
57
+ });
58
+ type Snapshot = z.output<typeof snapshotSchema>;
59
+
60
+ // Z.ai Team Plan reports CREDIT_LIMIT where BigModel reports TOKENS_LIMIT.
61
+ const TOKEN_LIMITS = new Set(["TOKENS_LIMIT", "CREDIT_LIMIT"]);
62
+ // The windows ZCode Desktop shows, identified by ZCode's unit codes
63
+ // (packages/ui/src/CodingPlanUsageRemainingPanel.tsx).
64
+ const WINDOWS: readonly {
65
+ id: string;
66
+ label: string;
67
+ shortLabel: string;
68
+ summary?: boolean;
69
+ matches(limit: QuotaLimit): boolean;
70
+ }[] = [
71
+ {
72
+ id: "five_hour",
73
+ label: "5-hour",
74
+ shortLabel: "5h",
75
+ summary: true,
76
+ matches: (limit) =>
77
+ TOKEN_LIMITS.has(limit.type) && limit.unit === 3 && limit.number === 5,
78
+ },
79
+ {
80
+ id: "weekly",
81
+ label: "Weekly",
82
+ shortLabel: "wk",
83
+ summary: true,
84
+ matches: (limit) => TOKEN_LIMITS.has(limit.type) && limit.unit === 6,
85
+ },
86
+ {
87
+ id: "tool_calls",
88
+ label: "Tool calls",
89
+ shortLabel: "tools",
90
+ matches: (limit) =>
91
+ limit.type === "TIME_LIMIT" && limit.unit === 5 && limit.number === 1,
92
+ },
93
+ ];
94
+
95
+ const noQuota = (detail: string): UsageReport => ({
96
+ status: "unavailable",
97
+ problem: { kind: "no_quota", detail },
98
+ });
99
+
100
+ function usageWindow(
101
+ definition: Omit<UsageWindow, "usedPct" | "remainingPct" | "resetsAt">,
102
+ limit: QuotaLimit,
103
+ ): UsageWindow {
104
+ // ZCode's percentage is the used share.
105
+ const used =
106
+ limit.percentage !== undefined && Number.isFinite(limit.percentage)
107
+ ? Math.max(0, Math.min(100, limit.percentage))
108
+ : null;
109
+ return {
110
+ ...definition,
111
+ usedPct: used,
112
+ remainingPct: used === null ? null : 100 - used,
113
+ resetsAt: limit.nextResetTime
114
+ ? new Date(limit.nextResetTime).toISOString()
115
+ : null,
116
+ tone:
117
+ used === null
118
+ ? "default"
119
+ : used > 90
120
+ ? "danger"
121
+ : used >= 70
122
+ ? "warning"
123
+ : "ok",
124
+ };
125
+ }
126
+
127
+ export function usageReport(snapshot: Snapshot): UsageReport {
128
+ switch (snapshot.unavailableReason) {
129
+ case undefined:
130
+ break;
131
+ case "no_plan":
132
+ return noQuota("No active Coding Plan");
133
+ case "not_configured":
134
+ case "not_authenticated":
135
+ return noQuota(
136
+ `No connected Coding Plan account. Open ${SETTINGS_PATH}.`,
137
+ );
138
+ default:
139
+ return {
140
+ status: "error",
141
+ error: "ZCode Coding Plan usage is unavailable",
142
+ };
143
+ }
144
+ const limits = snapshot.quota?.limits ?? [];
145
+ const windows: UsageWindow[] = [];
146
+ for (const { matches, ...definition } of WINDOWS) {
147
+ const limit = limits.find(matches);
148
+ if (limit) windows.push(usageWindow(definition, limit));
149
+ }
150
+ if (snapshot.mcpQuota)
151
+ windows.push(
152
+ usageWindow(
153
+ { id: "mcp", label: "ZCode MCP", shortLabel: "mcp" },
154
+ snapshot.mcpQuota.aggregate,
155
+ ),
156
+ );
157
+ if (windows.length === 0)
158
+ return {
159
+ status: "error",
160
+ error: "ZCode returned no Coding Plan quota. Try again later.",
161
+ };
162
+ const level = snapshot.quota?.level;
163
+ const planLabel =
164
+ snapshot.subscription?.details[0]?.productName ??
165
+ (level ? level.charAt(0).toUpperCase() + level.slice(1) : undefined);
166
+ return {
167
+ status: "available",
168
+ ...(planLabel ? { planLabel } : {}),
169
+ windows,
170
+ };
171
+ }
172
+
173
+ interface AccountState {
174
+ readonly session: z.output<typeof sessionStateSchema>;
175
+ readonly family: AccountFamily | undefined;
176
+ readonly view: NativeView;
177
+ }
178
+
179
+ async function readAccount(host: SettingsHost): Promise<AccountState> {
180
+ const [session, active, settings, view] = await Promise.all([
181
+ host.invoke("oauth", "restoreCachedSessionState", [], sessionStateSchema),
182
+ host.invoke("oauth", "getActiveProvider", [], familySchema),
183
+ host.invoke("setting", "get", [], accountSettingsSchema),
184
+ host.invoke("provider-settings", "getView", [], nativeViewSchema),
185
+ ]);
186
+ const domain = familySchema.safeParse(settings.providerFamilyDomain || null);
187
+ const family = (domain.success ? domain.data : null) ?? active ?? undefined;
188
+ return { session, family, view };
189
+ }
190
+
191
+ // Desktop's resolveEntitledAccountProviderAccess: the entitled individual
192
+ // Coding Plan of the signed-in family, with its static account access.
193
+ function planAccess(state: AccountState, providerId?: string) {
194
+ if (state.family === undefined) return [];
195
+ return state.view.providers.flatMap((provider) => {
196
+ const access = provider.effectiveConfig.access;
197
+ if (
198
+ (providerId !== undefined && provider.providerId !== providerId) ||
199
+ access?.type !== "zhipu-account" ||
200
+ access.accountType !== state.family ||
201
+ access.mode !== "individual-coding-plan" ||
202
+ access.entitled !== true
203
+ )
204
+ return [];
205
+ return [
206
+ {
207
+ providerId: provider.providerId,
208
+ access: {
209
+ type: "zhipu-account",
210
+ accountType: access.accountType,
211
+ mode: access.mode,
212
+ entitled: access.entitled,
213
+ },
214
+ },
215
+ ];
216
+ });
217
+ }
218
+
219
+ function accountKey(family: AccountFamily, userId: string | undefined) {
220
+ if (!userId || userId === "unknown") return `${family}.individual`;
221
+ // A stable, non-identifying key: the backend user ID is hashed.
222
+ const digest = createHash("sha256")
223
+ .update(`${family}:${userId}`)
224
+ .digest("hex")
225
+ .slice(0, 24);
226
+ return `${family}.individual.${digest}`;
227
+ }
228
+
229
+ export interface UsageSourceOptions {
230
+ /** Reuse discovered accounts this long; account changes clear them. */
231
+ readonly accountsTtlMs?: number;
232
+ readonly failureTtlMs?: number;
233
+ readonly now?: () => number;
234
+ }
235
+
236
+ /**
237
+ * Coding Plan quota for Paseo's Usage screen (Paseo 0.11+), read through
238
+ * ZCode's official usage-stats service on the account Server (ADR 18).
239
+ */
240
+ export function createUsageSource(
241
+ account: AccountService,
242
+ options: UsageSourceOptions = {},
243
+ ): { source: UsageSourceRegistration; dispose(): void } {
244
+ const accountsTtlMs = options.accountsTtlMs ?? 5 * 60_000;
245
+ const failureTtlMs = options.failureTtlMs ?? 30_000;
246
+ const now = options.now ?? Date.now;
247
+ let cached:
248
+ | { at: number; ttl: number; accounts: Promise<UsageAccount[]> }
249
+ | undefined;
250
+ const dispose = account.onChange(() => {
251
+ cached = undefined;
252
+ });
253
+
254
+ const discoverAccounts = async (): Promise<UsageAccount[]> => {
255
+ const state = await account.use(readAccount);
256
+ if (state.session.status === "signed-out" || state.family === undefined)
257
+ return [];
258
+ const family = state.family;
259
+ const displayName =
260
+ state.session.status === "authenticated"
261
+ ? state.session.userInfo.displayName
262
+ : undefined;
263
+ const userId =
264
+ state.session.status === "authenticated"
265
+ ? state.session.userInfo.id
266
+ : undefined;
267
+ return planAccess(state).map(({ providerId }) => ({
268
+ key: accountKey(family, userId),
269
+ label: displayName
270
+ ? `${FAMILY_LABELS[family]} (${displayName})`
271
+ : FAMILY_LABELS[family],
272
+ harness: "ZCode",
273
+ input: { providerId } satisfies UsageInput,
274
+ }));
275
+ };
276
+
277
+ const accounts = (): Promise<UsageAccount[]> => {
278
+ if (cached && now() - cached.at < cached.ttl) return cached.accounts;
279
+ const entry = {
280
+ at: now(),
281
+ ttl: accountsTtlMs,
282
+ accounts: discoverAccounts().catch((error: unknown) => {
283
+ // No runtime or no Server: no card, and a quick retry.
284
+ if (!(error instanceof AdapterError))
285
+ logger.error("zcode.usage.discover.failed", error);
286
+ entry.ttl = failureTtlMs;
287
+ return [];
288
+ }),
289
+ };
290
+ cached = entry;
291
+ return entry.accounts;
292
+ };
293
+
294
+ const source: UsageSourceRegistration = {
295
+ id: "zcode",
296
+ label: "ZCode",
297
+ icon: "icon.svg",
298
+ input: inputSchema,
299
+ async discover(scope: UsageScope): Promise<UsageAccount[]> {
300
+ const found = await accounts();
301
+ if (scope.kind === "global") return found;
302
+ if (scope.provider !== ZCODE_PROVIDER_ID) return [];
303
+ if (scope.model === undefined) return found;
304
+ let providerId: string;
305
+ try {
306
+ providerId = decodeModel(scope.model).providerId;
307
+ } catch {
308
+ return [];
309
+ }
310
+ return found.filter(
311
+ (entry) => (entry.input as UsageInput).providerId === providerId,
312
+ );
313
+ },
314
+ async fetch(input: unknown): Promise<UsageReport> {
315
+ const { providerId } = inputSchema.parse(input);
316
+ try {
317
+ return await account.use(async (host) => {
318
+ const state = await readAccount(host);
319
+ if (state.session.status === "signed-out")
320
+ return noQuota(`Signed out of ZCode. Open ${SETTINGS_PATH}.`);
321
+ if (state.session.status === "reauthentication-required")
322
+ return noQuota(
323
+ `ZCode sign-in expired. Sign in again in ${SETTINGS_PATH}.`,
324
+ );
325
+ const [plan] = planAccess(state, providerId);
326
+ if (!plan) return noQuota("No active Coding Plan");
327
+ // Desktop's request: this plan only, never an environment API key.
328
+ const snapshot = await host.invoke(
329
+ "usage-stats",
330
+ "getEntitlementSnapshot",
331
+ [
332
+ {
333
+ includeSubscription: true,
334
+ preferredProviderId: plan.providerId,
335
+ accountAccess: plan.access,
336
+ allowDisabledPreferredProvider: true,
337
+ requirePreferredProvider: true,
338
+ allowEnvApiKey: false,
339
+ },
340
+ ],
341
+ snapshotSchema,
342
+ 15_000,
343
+ );
344
+ return usageReport(snapshot);
345
+ });
346
+ } catch (error) {
347
+ if (
348
+ error instanceof AdapterError &&
349
+ error.code === "RUNTIME_SETUP_REQUIRED"
350
+ )
351
+ return { status: "error", error: error.message };
352
+ // Native messages may include account data; they stay in the log.
353
+ logger.error("zcode.usage.fetch.failed", error);
354
+ return {
355
+ status: "error",
356
+ error: "ZCode could not read Coding Plan usage",
357
+ };
358
+ }
359
+ },
360
+ };
361
+ return { source, dispose };
362
+ }
@@ -0,0 +1,201 @@
1
+ import { defineRpc } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ export const accountFamilySchema = z.enum(["zai", "bigmodel"]);
5
+ export type AccountFamily = z.output<typeof accountFamilySchema>;
6
+
7
+ export const apiFormatSchema = z.enum([
8
+ "anthropic-messages",
9
+ "openai-chat-completions",
10
+ "openai-responses",
11
+ ]);
12
+ export type ApiFormat = z.output<typeof apiFormatSchema>;
13
+
14
+ export const planModeSchema = z.enum([
15
+ "start-plan",
16
+ "individual-coding-plan",
17
+ "team-coding-plan",
18
+ "off-peak",
19
+ ]);
20
+
21
+ const modelSchema = z
22
+ .object({
23
+ modelId: z.string(),
24
+ enabled: z.boolean(),
25
+ /** Ready for new sessions: enabled, complete and entitled. */
26
+ selectable: z.boolean(),
27
+ /** Listed by ZCode's catalog rather than added by the user. */
28
+ builtin: z.boolean(),
29
+ contextWindow: z.number().optional(),
30
+ vision: z.boolean(),
31
+ })
32
+ .strict();
33
+
34
+ const planSchema = z
35
+ .object({
36
+ providerId: z.string(),
37
+ name: z.string(),
38
+ mode: planModeSchema,
39
+ availability: z.string(),
40
+ entitled: z.boolean(),
41
+ unavailableReason: z.string().optional(),
42
+ models: z.array(z.string()),
43
+ })
44
+ .strict();
45
+
46
+ // API keys never leave the daemon: the view only reports whether one is set.
47
+ const customProviderSchema = z
48
+ .object({
49
+ providerId: z.string(),
50
+ name: z.string(),
51
+ templateId: z.string().optional(),
52
+ enabled: z.boolean(),
53
+ executable: z.boolean(),
54
+ apiFormat: apiFormatSchema.optional(),
55
+ baseUrl: z.string().optional(),
56
+ apiKeySet: z.boolean(),
57
+ apiKeyManagementUrl: z.string().optional(),
58
+ issues: z.array(z.string()),
59
+ models: z.array(modelSchema),
60
+ })
61
+ .strict();
62
+
63
+ const templateSchema = z
64
+ .object({
65
+ templateId: z.string(),
66
+ name: z.string(),
67
+ apiFormat: apiFormatSchema.optional(),
68
+ baseUrl: z.string().optional(),
69
+ apiKeyManagementUrl: z.string().optional(),
70
+ })
71
+ .strict();
72
+
73
+ export const signInStateSchema = z.discriminatedUnion("state", [
74
+ z.object({ state: z.literal("idle") }).strict(),
75
+ z
76
+ .object({
77
+ state: z.literal("pending"),
78
+ family: accountFamilySchema,
79
+ /** Open it on any device; the daemon receives the result. */
80
+ authorizeUrl: z.string(),
81
+ expiresAt: z.number(),
82
+ })
83
+ .strict(),
84
+ z.object({ state: z.literal("failed"), message: z.string() }).strict(),
85
+ ]);
86
+
87
+ export const accountViewSchema = z.discriminatedUnion("status", [
88
+ z
89
+ .object({
90
+ status: z.literal("unavailable"),
91
+ code: z.string(),
92
+ message: z.string(),
93
+ })
94
+ .strict(),
95
+ z
96
+ .object({
97
+ status: z.literal("ready"),
98
+ account: z.discriminatedUnion("state", [
99
+ z.object({ state: z.literal("signed-out") }).strict(),
100
+ z
101
+ .object({
102
+ state: z.enum(["signed-in", "reauthentication-required"]),
103
+ family: accountFamilySchema.optional(),
104
+ displayName: z.string().optional(),
105
+ username: z.string().optional(),
106
+ })
107
+ .strict(),
108
+ ]),
109
+ signIn: signInStateSchema,
110
+ plans: z.array(planSchema),
111
+ providers: z.array(customProviderSchema),
112
+ templates: z.array(templateSchema),
113
+ })
114
+ .strict(),
115
+ ]);
116
+
117
+ export type AccountView = z.output<typeof accountViewSchema>;
118
+ export type ReadyAccountView = Extract<AccountView, { status: "ready" }>;
119
+ export type CustomProvider = ReadyAccountView["providers"][number];
120
+ export type ProviderTemplate = ReadyAccountView["templates"][number];
121
+
122
+ const id = z.string().trim().min(1).max(200);
123
+ const name = z.string().trim().min(1).max(100);
124
+ // React Native's URL polyfill lacks protocol and host, so this is a pattern.
125
+ // ZCode parses the URL itself when it saves the provider.
126
+ export const isHttpUrl = (value: string) =>
127
+ /^https?:\/\/[^\s/?#]+[^\s]*$/iu.test(value);
128
+ const baseUrl = z
129
+ .string()
130
+ .trim()
131
+ .max(2048)
132
+ .refine(isHttpUrl, "Enter an http(s) URL");
133
+ const apiKey = z.string().trim().min(1).max(4096);
134
+
135
+ export const accountChangeSchema = z.discriminatedUnion("action", [
136
+ z
137
+ .object({ action: z.literal("signIn"), family: accountFamilySchema })
138
+ .strict(),
139
+ z.object({ action: z.literal("cancelSignIn") }).strict(),
140
+ z.object({ action: z.literal("signOut") }).strict(),
141
+ z
142
+ .object({
143
+ action: z.literal("createProvider"),
144
+ templateId: id.optional(),
145
+ name,
146
+ apiFormat: apiFormatSchema,
147
+ baseUrl,
148
+ apiKey,
149
+ })
150
+ .strict(),
151
+ z
152
+ .object({
153
+ action: z.literal("updateProvider"),
154
+ providerId: id,
155
+ name: name.optional(),
156
+ apiFormat: apiFormatSchema.optional(),
157
+ baseUrl: baseUrl.optional(),
158
+ apiKey: apiKey.optional(),
159
+ enabled: z.boolean().optional(),
160
+ })
161
+ .strict(),
162
+ z.object({ action: z.literal("deleteProvider"), providerId: id }).strict(),
163
+ z
164
+ .object({
165
+ action: z.literal("addModel"),
166
+ providerId: id,
167
+ modelId: id,
168
+ })
169
+ .strict(),
170
+ z
171
+ .object({
172
+ action: z.literal("deleteModel"),
173
+ providerId: id,
174
+ modelId: id,
175
+ })
176
+ .strict(),
177
+ z
178
+ .object({
179
+ action: z.literal("setModelEnabled"),
180
+ providerId: id,
181
+ modelId: id,
182
+ enabled: z.boolean(),
183
+ })
184
+ .strict(),
185
+ ]);
186
+
187
+ export type AccountChange = z.input<typeof accountChangeSchema>;
188
+
189
+ export const zcodeAccountView = defineRpc({
190
+ name: "zcode.account.view",
191
+ input: z.object({}).strict(),
192
+ output: accountViewSchema,
193
+ });
194
+
195
+ // Changes go through ZCode's own account and provider services, which own
196
+ // validation and storage. The result is the refreshed view.
197
+ export const zcodeAccountChange = defineRpc({
198
+ name: "zcode.account.change",
199
+ input: accountChangeSchema,
200
+ output: accountViewSchema,
201
+ });
@@ -45,8 +45,6 @@ export const runtimeSetupStatusSchema = z
45
45
  directory: z.string(),
46
46
  components: z.array(runtimeComponentSchema),
47
47
  job: setupJobSchema,
48
- /** Present once both components are installed; run it on the daemon machine. */
49
- loginCommand: z.string().optional(),
50
48
  })
51
49
  .strict();
52
50