diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e2769ab807..618c016de3 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -21,6 +21,18 @@ ocx models provider openrouter on After GUI registration or OAuth login, the confirmation dialog lets you open the Models page. CLI registration and login print model-management commands; JSON includes structured next steps. `--no-wait` reports pending login, not completion. Start the proxy with `ocx start` before using live model commands. +## Z.ai Coding Plan quota endpoints + +The Z.ai quota probe recognizes the international coding Chat base +`https://api.z.ai/api/coding/paas/v4`, the documented +[Claude Code Anthropic base](https://docs.z.ai/devpack/tool/claude) +`https://api.z.ai/api/anthropic`, and the documented +[Codex Responses base](https://docs.z.ai/devpack/tool/codex) +`https://api.z.ai/api/v1`. All three read quota from the international monitor with +Bearer authentication; this does not change the inference URL or imply different +quota consumption between adapters. Existing BigModel CN monitor selection remains +separate. Full request URLs such as `/api/v1/responses` are not provider base URLs. + ## Provider-related top-level fields | Field | Type | Default | Meaning | diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 79e6a6bf91..69ee60626c 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -345,14 +345,26 @@ function isCanonicalOllamaCloudBaseUrl(baseUrl?: string): boolean { } } +function zaiQuotaMonitorHost(baseUrl: string): string | null { + // Admission and destination selection must share one mapping: admitting a new + // international wire must never fall through to the CN host/authentication scheme. + switch (normalizedBaseUrl(baseUrl)) { + case ZAI_BASE_URL: + case `${ZAI_BASE_URL}/api/coding/paas/v4`: + case `${ZAI_BASE_URL}/api/anthropic`: + case `${ZAI_BASE_URL}/api/v1`: + return ZAI_BASE_URL; + case ZAI_CN_BASE_URL: + case `${ZAI_CN_BASE_URL}/api/coding/paas/v4`: + case `${ZAI_CN_BASE_URL}/api/v1`: + return ZAI_CN_BASE_URL; + default: + return null; + } +} + function isCanonicalZaiBaseUrl(baseUrl: string): boolean { - const normalized = normalizedBaseUrl(baseUrl); - return normalized === ZAI_BASE_URL - || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` - || normalized === ZAI_CN_BASE_URL - || normalized === `${ZAI_CN_BASE_URL}/api/coding/paas/v4` - // BigModel serves the same GLM Coding Plan on the OpenAI Responses wire at /api/v1. - || normalized === `${ZAI_CN_BASE_URL}/api/v1`; + return zaiQuotaMonitorHost(baseUrl) !== null; } function isCanonicalMinimaxBaseUrl(baseUrl: string): boolean { @@ -851,13 +863,10 @@ function parseZaiQuotaLegacyFields(data: Record | null): Provid * host or follow a redirect off-origin. */ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise { - if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null; + const monitorHost = zaiQuotaMonitorHost(config.baseUrl); + if (!monitorHost) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; - const normalized = normalizedBaseUrl(config.baseUrl); - const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4` - ? ZAI_BASE_URL - : ZAI_CN_BASE_URL; const authorization = monitorHost === ZAI_CN_BASE_URL ? apiKey : `Bearer ${apiKey}`; const response = await fetch(`${monitorHost}/api/monitor/usage/quota/limit`, { headers: { Accept: "application/json", Authorization: authorization }, diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 2c21897855..f9d4fb79ec 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -506,6 +506,22 @@ once it exceeds 200) with the upstream content-type, body kind (`sse / json / ot body sample, and the extracted usage. Off by default; the hot path is guarded so production stays untouched. +## Z.ai quota destination ownership + +`src/providers/quota.ts` uses one exact normalized-base mapping for both Z.ai quota +eligibility and monitor selection. International root, coding Chat, Anthropic and +Responses bases use `api.z.ai` with Bearer authentication. Existing BigModel CN root, +coding Chat and Responses bases use `open.bigmodel.cn` with the raw key. Unsupported +bases produce no probe; redirect refusal and quota parsing/cache semantics are unchanged. + +[Decision Log] +- 목적과 의도: Restore quota reads for documented international Anthropic and Responses bases without changing their inference configuration. +- 기존 구현 및 제약 조건: Admission omitted both bases; a separate monitor ternary treated all other admitted bases as CN. +- 검토한 주요 대안: Add the same paths to two lists, accept any path on either host, or share one exact mapping. +- 선택한 방식: Share one base-to-monitor mapping and preserve the existing CN allowlist. +- 다른 대안 대신 이 방식을 선택한 이유: A single mapping prevents new international admission from silently selecting the CN authentication scheme, without admitting unrelated pay-as-you-go paths. +- 장점, 단점 및 영향: No config migration or inference change; new documented endpoints still require an explicit reviewed mapping entry. Quota-consumption differences are not inferred from adapter choice. + ## Provider debug logging Provider transport diagnostics (dropped SSE frames, adapter dial/stream events, etc.) are opt-in: diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index a8d4bef728..56d69951d5 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -1014,6 +1014,70 @@ describe("fetchProviderQuotaReports", () => { expect(seen[0]?.redirect).toBe("error"); }); + test.each([ + ["https://api.z.ai/api/anthropic", "anthropic"], + ["https://api.z.ai/api/v1", "openai-responses"], + ["https://API.Z.AI/api/anthropic/", "anthropic"], + ["https://api.z.ai/api/v1/", "openai-responses"], + ] as const)("Z.AI quota uses the international monitor for %s (%s)", async (baseUrl, adapter) => { + const seen: Array<{ url: string; authorization: string | null; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + seen.push({ url: String(input), authorization: new Headers(init?.headers).get("authorization"), redirect: init?.redirect }); + return Response.json({ success: true, data: { limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 25 }, + ] } }); + }) as typeof fetch; + const config = keyQuotaConfig("zai", baseUrl); + config.providers.zai!.adapter = adapter; + + const result = await fetchProviderQuotaReports(config, true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota.fiveHourPercent).toBe(25); + expect(seen).toEqual([{ + url: "https://api.z.ai/api/monitor/usage/quota/limit", + authorization: "Bearer zai-secret", + redirect: "error", + }]); + }); + + test.each([ + "https://api.z.ai.example/api/anthropic", + "http://api.z.ai/api/anthropic", + "https://api.z.ai:8443/api/v1", + "https://api.z.ai/api/anthropic?region=cn", + "https://api.z.ai/api/v1#fragment", + "https://api.z.ai/api/anthropic/v1/messages", + "https://api.z.ai/api/v1/responses", + "https://api.z.ai/api/paas/v4", + "https://open.bigmodel.cn/api/anthropic", + ])("Z.AI quota does not probe unsupported base %s", async baseUrl => { + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + expect((await fetchProviderQuotaReports(keyQuotaConfig("zai", baseUrl), true)).reports).toEqual([]); + expect(calls).toBe(0); + }); + + test.each(["username", "password"] as const)("Z.AI quota rejects URL %s before probing", async field => { + // Construct dummy userinfo instead of embedding an email-shaped fixture in source. + // Keep this distinct from host/path rejection: the monitor host would otherwise match. + const url = new URL("https://api.z.ai/api/anthropic"); + url[field] = "fixture"; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + expect((await fetchProviderQuotaReports(keyQuotaConfig("zai", url.href), true)).reports).toEqual([]); + expect(calls).toBe(0); + }); + test("Z.AI quota probes the BigModel region from the provider's own host", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {