Skip to content

Commit bc5c20e

Browse files
authored
Merge pull request #78 from mengfanbo123/add-zhipu-provider
feat: add Zhipu Coding Plan provider (domestic China)
2 parents fc132df + 237ba7a commit bc5c20e

13 files changed

Lines changed: 1215 additions & 21 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ Most providers work automatically. If a provider has a “Needs setup” link, o
207207
| Google Antigravity | [Needs setup](#google-antigravity) | Remote API |
208208
| Gemini CLI | [Needs setup](#gemini-cli) | Remote API |
209209
| Z.ai Coding Plan | Automatic | Remote API |
210+
| Zhipu Coding Plan | Automatic | Remote API |
210211
| NanoGPT | Usually automatic | Remote API |
211212
| OpenCode Go | [Needs setup](#opencode-go) | Dashboard scraping |
212213

@@ -572,7 +573,7 @@ Run `/quota_status` and check the Alibaba auth, resolved tier, state-file path,
572573
</details>
573574

574575
<details>
575-
<summary><strong>MiniMax, Kimi, Chutes AI, Crof.ai, Synthetic, Z.ai, and NanoGPT</strong></summary>
576+
<summary><strong>MiniMax, Kimi, Chutes AI, Crof.ai, Synthetic, Z.ai, Zhipu, and NanoGPT</strong></summary>
576577

577578
These providers use trusted env vars, trusted user/global OpenCode config, or native OpenCode auth. Run `/quota_status` and check the provider-specific API-key diagnostics. Crof.ai is env/config only.
578579

@@ -584,6 +585,7 @@ These providers use trusted env vars, trusted user/global OpenCode config, or na
584585
| Crof.ai | Use `CROF_API_KEY`, `CROFAI_API_KEY`, or trusted user/global config. |
585586
| Synthetic | Use `SYNTHETIC_API_KEY`, trusted user/global config, or OpenCode auth. |
586587
| Z.ai Coding Plan | Use `ZAI_API_KEY` or `ZAI_CODING_PLAN_API_KEY`; malformed fallback auth is surfaced as an auth error. |
588+
| Zhipu Coding Plan | Use `ZHIPU_API_KEY` or `ZHIPU_CODING_PLAN_API_KEY`; malformed fallback auth is surfaced as an auth error. |
587589
| NanoGPT | Use `NANOGPT_API_KEY`, `NANO_GPT_API_KEY`, trusted user/global config, or OpenCode auth. |
588590

589591
For security, repo-local `opencode.json` / `opencode.jsonc` is ignored for provider secrets in these integrations. Put secrets in environment variables or trusted user/global config.

src/lib/provider-metadata.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type CanonicalQuotaProviderId =
1111
| "google-antigravity"
1212
| "google-gemini-cli"
1313
| "zai"
14+
| "zhipu"
1415
| "nanogpt"
1516
| "minimax-coding-plan"
1617
| "kimi-for-coding"
@@ -60,6 +61,7 @@ export const QUOTA_PROVIDER_LABELS: Readonly<Record<string, string>> = {
6061
"qwen-code": "Qwen",
6162
"alibaba-coding-plan": "Alibaba Coding Plan",
6263
zai: "Z.ai",
64+
zhipu: "Zhipu",
6365
nanogpt: "NanoGPT",
6466
"minimax-coding-plan": "MiniMax Coding Plan",
6567
"kimi-for-coding": "Kimi Code",
@@ -88,6 +90,8 @@ export const QUOTA_PROVIDER_ID_SYNONYMS: Readonly<Record<string, string>> = {
8890
"google-gemini": "google-gemini-cli",
8991
"opencode-gemini-auth": "google-gemini-cli",
9092
gemini: "google-gemini-cli",
93+
"glm-coding-plan": "zhipu",
94+
"zhipu-coding-plan": "zhipu",
9195
};
9296

9397
export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = {
@@ -109,6 +113,7 @@ export const QUOTA_PROVIDER_RUNTIME_IDS: QuotaProviderRuntimeIds = {
109113
"google",
110114
],
111115
zai: ["zai", "glm", "zai-coding-plan"],
116+
zhipu: ["zhipu", "glm-coding-plan", "zhipu-coding-plan"],
112117
nanogpt: ["nanogpt", "nano-gpt"],
113118
"minimax-coding-plan": ["minimax-coding-plan", "minimax"],
114119
"kimi-for-coding": ["kimi-for-coding", "kimi", "kimi-code"],
@@ -207,6 +212,13 @@ export const QUOTA_PROVIDER_SHAPES: readonly QuotaProviderShape[] = [
207212
authFallbacks: ["env_api_key", "global_opencode_config"],
208213
quota: "remote_api",
209214
},
215+
{
216+
id: "zhipu",
217+
autoSetup: "yes",
218+
authentication: "opencode_auth_api_key",
219+
authFallbacks: ["env_api_key", "global_opencode_config"],
220+
quota: "remote_api",
221+
},
210222
{
211223
id: "nanogpt",
212224
autoSetup: "usually",

src/lib/quota-status.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
resolveMiniMaxAuthCached,
3535
} from "./minimax-auth.js";
3636
import { DEFAULT_ZAI_AUTH_CACHE_MAX_AGE_MS, getZaiAuthDiagnostics } from "./zai-auth.js";
37+
import { DEFAULT_ZHIPU_AUTH_CACHE_MAX_AGE_MS, getZhipuAuthDiagnostics } from "./zhipu-auth.js";
3738
import {
3839
DEFAULT_KIMI_AUTH_CACHE_MAX_AGE_MS,
3940
getKimiAuthDiagnostics,
@@ -92,6 +93,7 @@ import type {
9293
} from "./types.js";
9394
import { queryMiniMaxQuota } from "../providers/minimax-coding-plan.js";
9495
import { queryZaiQuota } from "./zai.js";
96+
import { queryZhipuQuota } from "./zhipu.js";
9597
import {
9698
getOpenCodeGoConfigDiagnostics,
9799
resolveOpenCodeGoConfigCached,
@@ -1187,6 +1189,55 @@ export async function buildQuotaStatusReport(params: {
11871189
appendProviderCompactLiveProbeRows(zaiRows, "zai", params.providerLiveProbes);
11881190
sections.push(createKvSection("zai", "zai:", zaiRows));
11891191

1192+
// === zhipu ===
1193+
const zhipuRows: ReportKvRow[] = [];
1194+
const zhipuAuth = await getZhipuAuthDiagnostics({
1195+
maxAgeMs: DEFAULT_ZHIPU_AUTH_CACHE_MAX_AGE_MS,
1196+
});
1197+
zhipuRows.push({ key: "auth_state", value: zhipuAuth.state });
1198+
zhipuRows.push({
1199+
key: "api_key_configured",
1200+
value: zhipuAuth.state === "configured" ? "true" : "false",
1201+
});
1202+
zhipuRows.push({ key: "api_key_source", value: zhipuAuth.source ?? "(none)" });
1203+
zhipuRows.push({ key: "api_key_checked_paths", value: joinOrNone(zhipuAuth.checkedPaths) });
1204+
zhipuRows.push({ key: "api_key_auth_paths", value: joinOrNone(zhipuAuth.authPaths) });
1205+
if (zhipuAuth.state === "invalid") {
1206+
zhipuRows.push({ key: "auth_error", value: sanitizeDisplayText(zhipuAuth.error) });
1207+
}
1208+
if (zhipuAuth.state === "configured") {
1209+
const zhipuQuota = await queryZhipuQuota();
1210+
if (!zhipuQuota) {
1211+
zhipuRows.push({ key: "live_fetch_error", value: "Zhipu API key became unavailable before fetch" });
1212+
} else if (!zhipuQuota.success) {
1213+
zhipuRows.push({ key: "live_fetch_error", value: zhipuQuota.error });
1214+
} else {
1215+
if (zhipuQuota.windows.fiveHour) {
1216+
zhipuRows.push({
1217+
key: "five_hour_remaining",
1218+
value: `${zhipuQuota.windows.fiveHour.percentRemaining}% reset_at=${zhipuQuota.windows.fiveHour.resetTimeIso ?? "(none)"}`,
1219+
});
1220+
}
1221+
if (zhipuQuota.windows.weekly) {
1222+
zhipuRows.push({
1223+
key: "weekly_remaining",
1224+
value: `${zhipuQuota.windows.weekly.percentRemaining}% reset_at=${zhipuQuota.windows.weekly.resetTimeIso ?? "(none)"}`,
1225+
});
1226+
}
1227+
if (zhipuQuota.windows.mcp) {
1228+
zhipuRows.push({
1229+
key: "mcp_remaining",
1230+
value: `${zhipuQuota.windows.mcp.percentRemaining}% reset_at=${zhipuQuota.windows.mcp.resetTimeIso ?? "(none)"}`,
1231+
});
1232+
}
1233+
if (!zhipuQuota.windows.fiveHour && !zhipuQuota.windows.weekly && !zhipuQuota.windows.mcp) {
1234+
zhipuRows.push({ key: "live_state", value: "no reportable Zhipu quota windows" });
1235+
}
1236+
}
1237+
}
1238+
appendProviderCompactLiveProbeRows(zhipuRows, "zhipu", params.providerLiveProbes);
1239+
sections.push(createKvSection("zhipu", "zhipu:", zhipuRows));
1240+
11901241
// === simple API key sections ===
11911242
const syntheticDiag = await readBasicApiKeyDiagnostics(getSyntheticKeyDiagnostics);
11921243
const syntheticRows: ReportKvRow[] = [

src/lib/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,10 @@ export interface AuthData {
349349
type: "api";
350350
key: string;
351351
};
352+
"zhipu-coding-plan"?: {
353+
type: "api";
354+
key: string;
355+
};
352356
"minimax-coding-plan"?: MiniMaxAuthData;
353357
"kimi-code"?: KimiAuthData;
354358
kimi?: KimiAuthData;

src/lib/zhipu-auth.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import {
2+
extractProviderOptionsApiKey,
3+
getApiKeyCheckedPaths,
4+
getFirstAuthEntryValue,
5+
getGlobalOpencodeConfigCandidatePaths,
6+
resolveApiKeyFromEnvAndConfig,
7+
} from "./api-key-resolver.js";
8+
import { sanitizeDisplayText } from "./display-sanitize.js";
9+
import { getAuthPaths, readAuthFileCached } from "./opencode-auth.js";
10+
11+
import type { AuthData, ZaiAuthData } from "./types.js";
12+
13+
export const DEFAULT_ZHIPU_AUTH_CACHE_MAX_AGE_MS = 5_000;
14+
const ZHIPU_AUTH_KEYS = ["zhipu-coding-plan"] as const;
15+
const ZHIPU_PROVIDER_KEYS = ["zhipu", "zhipu-coding-plan", "glm-coding-plan"] as const;
16+
const ALLOWED_ZHIPU_ENV_VARS = ["ZHIPU_API_KEY", "ZHIPU_CODING_PLAN_API_KEY"] as const;
17+
18+
export type ZhipuKeySource =
19+
| "env:ZHIPU_API_KEY"
20+
| "env:ZHIPU_CODING_PLAN_API_KEY"
21+
| "opencode.json"
22+
| "opencode.jsonc"
23+
| "auth.json";
24+
25+
export type ResolvedZhipuAuth =
26+
| { state: "none" }
27+
| { state: "configured"; apiKey: string }
28+
| { state: "invalid"; error: string };
29+
30+
export type ZhipuAuthDiagnostics =
31+
| {
32+
state: "none";
33+
source: null;
34+
checkedPaths: string[];
35+
authPaths: string[];
36+
}
37+
| {
38+
state: "configured";
39+
source: ZhipuKeySource;
40+
checkedPaths: string[];
41+
authPaths: string[];
42+
}
43+
| {
44+
state: "invalid";
45+
source: "auth.json";
46+
checkedPaths: string[];
47+
authPaths: string[];
48+
error: string;
49+
};
50+
51+
export { getGlobalOpencodeConfigCandidatePaths as getOpencodeConfigCandidatePaths } from "./api-key-resolver.js";
52+
53+
function getZhipuAuthEntry(auth: AuthData | null | undefined): unknown {
54+
return getFirstAuthEntryValue(auth, ZHIPU_AUTH_KEYS);
55+
}
56+
57+
function isZhipuAuthData(value: unknown): value is ZaiAuthData {
58+
return value !== null && typeof value === "object";
59+
}
60+
61+
function sanitizeZhipuAuthValue(value: string): string {
62+
const sanitized = sanitizeDisplayText(value).replace(/\s+/g, " ").trim();
63+
return (sanitized || "unknown").slice(0, 120);
64+
}
65+
66+
export function resolveZhipuAuth(auth: AuthData | null | undefined): ResolvedZhipuAuth {
67+
const zhipu = getZhipuAuthEntry(auth);
68+
if (zhipu === null || zhipu === undefined) {
69+
return { state: "none" };
70+
}
71+
72+
if (!isZhipuAuthData(zhipu)) {
73+
return { state: "invalid", error: "Zhipu auth entry has invalid shape" };
74+
}
75+
76+
if (typeof zhipu.type !== "string") {
77+
return { state: "invalid", error: "Zhipu auth entry present but type is missing or invalid" };
78+
}
79+
80+
if (zhipu.type !== "api") {
81+
return {
82+
state: "invalid",
83+
error: `Unsupported Zhipu auth type: "${sanitizeZhipuAuthValue(zhipu.type)}"`,
84+
};
85+
}
86+
87+
const key = typeof zhipu.key === "string" ? zhipu.key.trim() : "";
88+
if (!key) {
89+
return { state: "invalid", error: "Zhipu auth entry present but key is empty" };
90+
}
91+
92+
return { state: "configured", apiKey: key };
93+
}
94+
95+
async function resolveZhipuAuthWithSource(params?: {
96+
maxAgeMs?: number;
97+
}): Promise<{ auth: ResolvedZhipuAuth; source: ZhipuKeySource | null }> {
98+
const resolvedFromEnvOrConfig = await resolveApiKeyFromEnvAndConfig<ZhipuKeySource>({
99+
envVars: [
100+
{ name: "ZHIPU_API_KEY", source: "env:ZHIPU_API_KEY" },
101+
{
102+
name: "ZHIPU_CODING_PLAN_API_KEY",
103+
source: "env:ZHIPU_CODING_PLAN_API_KEY",
104+
},
105+
],
106+
extractFromConfig: (config) =>
107+
extractProviderOptionsApiKey(config, {
108+
providerKeys: ZHIPU_PROVIDER_KEYS,
109+
allowedEnvVars: ALLOWED_ZHIPU_ENV_VARS,
110+
}),
111+
configJsonSource: "opencode.json",
112+
configJsoncSource: "opencode.jsonc",
113+
getConfigCandidates: getGlobalOpencodeConfigCandidatePaths,
114+
});
115+
116+
if (resolvedFromEnvOrConfig) {
117+
return {
118+
auth: { state: "configured", apiKey: resolvedFromEnvOrConfig.key },
119+
source: resolvedFromEnvOrConfig.source,
120+
};
121+
}
122+
123+
const maxAgeMs = Math.max(0, params?.maxAgeMs ?? DEFAULT_ZHIPU_AUTH_CACHE_MAX_AGE_MS);
124+
const authData = await readAuthFileCached({ maxAgeMs });
125+
const auth = resolveZhipuAuth(authData);
126+
127+
return {
128+
auth,
129+
source: auth.state === "none" ? null : "auth.json",
130+
};
131+
}
132+
133+
export async function resolveZhipuAuthCached(params?: {
134+
maxAgeMs?: number;
135+
}): Promise<ResolvedZhipuAuth> {
136+
return (await resolveZhipuAuthWithSource(params)).auth;
137+
}
138+
139+
export async function getZhipuAuthDiagnostics(params?: {
140+
maxAgeMs?: number;
141+
}): Promise<ZhipuAuthDiagnostics> {
142+
const { auth, source } = await resolveZhipuAuthWithSource(params);
143+
const checkedPaths = getApiKeyCheckedPaths({
144+
envVarNames: [...ALLOWED_ZHIPU_ENV_VARS],
145+
getConfigCandidates: getGlobalOpencodeConfigCandidatePaths,
146+
});
147+
const authPaths = getAuthPaths();
148+
149+
if (auth.state === "none") {
150+
return {
151+
state: "none",
152+
source: null,
153+
checkedPaths,
154+
authPaths,
155+
};
156+
}
157+
158+
if (auth.state === "invalid") {
159+
return {
160+
state: "invalid",
161+
source: "auth.json",
162+
checkedPaths,
163+
authPaths,
164+
error: auth.error,
165+
};
166+
}
167+
168+
return {
169+
state: "configured",
170+
source: source ?? "auth.json",
171+
checkedPaths,
172+
authPaths,
173+
};
174+
}

0 commit comments

Comments
 (0)