Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
33 changes: 21 additions & 12 deletions src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -851,13 +863,10 @@ function parseZaiQuotaLegacyFields(data: Record<string, unknown> | null): Provid
* host or follow a redirect off-origin.
*/
async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
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 },
Expand Down
16 changes: 16 additions & 0 deletions structure/05_gui-and-management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions tests/providers/provider-quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading