diff --git a/devlog/_plan/260911_l2_catalog_provider/000_packet.md b/devlog/_plan/260911_l2_catalog_provider/000_packet.md new file mode 100644 index 0000000000..529a5f5e33 --- /dev/null +++ b/devlog/_plan/260911_l2_catalog_provider/000_packet.md @@ -0,0 +1,85 @@ +# Dispatch packet — L2 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L2 — provider quota and registry + +Worktree `~/.codex/worktrees/260911-l2/opencodex`, branch `codex/260911-l2-catalog-provider`. + +Owned: `src/providers/quota.ts`, `quota-types.ts`, `quota-wire.ts`, `quota-routing-cache.ts`, +`quota-key-accounts.ts`, `account-quota-disk.ts`, `registry.ts` (all under `src/providers/`), plus +`tests/providers/provider-registry-parity.test.ts`, the oracle that locks the roster you are changing: +it asserts the two-model list at `:464` and that `glm-5.3-flash` is absent at `:508`, so the catalog +half cannot land without updating it. + +1. **#4201 — BigModel Responses Coding Plan: missing quota probe and GLM-5.3-Flash catalog support** + (reporter `bluesmilery`). **Decision: do not build on #4210.** It is an open draft by `Ingwannu` + at `REVIEW_REQUIRED` and it also edits `docs-site/src/content/docs/guides/providers.md`, which + belongs to L7. Implement #4201 independently; if your diff would overlap #4210's `quota.ts` hunks, + report that overlap to the orchestrator instead of merging the two lines of work. If the fix needs + documentation, write the wording in your report and let L7 land it. + +`quota.ts` is contended by four open PRs; keep the change surgical. + diff --git a/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md b/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md new file mode 100644 index 0000000000..ace9c3c436 --- /dev/null +++ b/devlog/_plan/260911_l2_catalog_provider/010_4201_quota_admission.md @@ -0,0 +1,98 @@ +# 010 — #4201 work-phase: Responses preset quota admission + +Lane L2, branch `codex/260911-l2-catalog-provider`, rebased onto `origin/dev` `ed839a3ee`. + +## What the issue asked for + +#4201 reports two user-visible gaps behind one subscription. Switching the domestic GLM Coding +Plan from the Chat preset to the Responses preset loses the quota display and the +`glm-5.3-flash` entry, so the same plan looks halved after a wire-format change. + +## What landed + +Only the quota half. + +`keyQuotaReaderForProvider` at `src/providers/quota.ts:2909` gated the Z.AI/BigModel reader on a +provider-name list of `zai`, `glm`, `glm-cn`, `zhipu-bigmodel-coding`. The destination check +`isCanonicalZaiBaseUrl` at `:355` already accepted `https://open.bigmodel.cn/api/v1`, and +`fetchZaiQuota` at `:858` already selects the domestic monitor host and its bare-key +`Authorization` convention for that base. The name list was the whole gap: +`providerApiKeyQuotaMode` returned `unsupported` and `fetchProviderApiKeyQuotas` returned `[]` +before any request existed. + +The fix adds the one name. Eligibility stays a conjunction of the name list and the canonical-URL +guard, because that guard is what keeps the bare key from travelling to a lookalike host: a +same-named custom provider resolves no reader and therefore dispatches nothing at all. + +Regression: `tests/providers/zhipu-bigmodel-responses-quota.test.ts`, registered in +`scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. Four cases — +eligibility anchored to the registry entry's own `baseUrl`, the negative controls (custom host, +pay-as-you-go endpoint, disabled, non-key auth modes), the domestic dispatch with a bare +`Authorization` and `redirect: "error"`, and a no-dispatch proof for a same-named custom +destination. + +It is a separate file on purpose: `tests/providers/provider-quota.test.ts` is contended by four +open PRs and is not in this lane's owned paths. + +## Decision: Flash is not seeded + +`glm-5.3-flash` stays out of the Responses roster, so the pull request says `Refs #4201`, not +`Closes`. + +The issue itself makes Flash conditional — "should offer `glm-5.3-flash` **if** the domestic +Responses endpoint supports it, with verified context, modalities, and reasoning metadata" — and +this lane has no endpoint-specific evidence. The registry comment at `registry.ts:2640` records +that the static roster is deliberate and that the official Codex example is a local catalog file, +not an HTTP `/models` contract, and the roster oracle at +`tests/providers/provider-registry-parity.test.ts:464` and `:508` locks that shape. The +maintainer review on the issue reaches the same conclusion: admit the quota name now, seed Flash +only after endpoint proof. + +Seeding it anyway would mean inventing a context window, modalities and a reasoning ladder for a +model this endpoint has not been observed to serve. That is the fabrication the oracle exists to +prevent, so the honest outcome is a closed quota half and an open, evidence-blocked Flash half. + +## Documentation for L7 + +`docs-site/src/content/docs/guides/providers.md` is L7's path. Two edits follow from this change; +the wording is here so L7 can land it. + +At `:947`, the eligible-preset sentence is now stale. Current text: + +> **Z.AI GLM Coding Plan quota.** The `zai`, `glm`, `glm-cn`, and `zhipu-bigmodel-coding` +> presets read `GET /api/monitor/usage/quota/limit` and do not follow redirects. + +Replacement: + +> **Z.AI GLM Coding Plan quota.** The `zai`, `glm`, `glm-cn`, `zhipu-bigmodel-coding`, and +> `zhipu-bigmodel-responses` presets read `GET /api/monitor/usage/quota/limit` and do not follow +> redirects. + +In the "BigModel Coding Plan over Responses" section, after the paragraph ending at `:783`, one +sentence closing the parity question the issue raises: + +> Quota comes from the same Coding Plan probe as the Chat preset, because it is the same +> subscription: the Responses endpoint `/api/v1` is a canonical destination for that probe and +> uses the same domestic bare-key `Authorization`. + +The Flash restriction is already documented at `:788` ("`glm-5.3-flash` is not seeded here +because its exact Responses metadata is not verified"), which is the wording #4201 asks for. No +change needed there beyond keeping it true. + +## Overlap reported to the orchestrator + +Open draft #4210 by `Ingwannu` (head `b9109a151`, `REVIEW_REQUIRED`) fixes the same selector line. +The packet's recorded decision is to implement #4201 independently and report the overlap rather +than merge the two lines of work, which is what happened: the diffs collide on +`src/providers/quota.ts:2909` because there is no second way to express this fix. #4210 also edits +`tests/providers/provider-quota.test.ts` and `docs-site/.../providers.md`, neither of which this +lane owns. `Ingwannu` is credited with a `Co-authored-by` trailer per `AGENTS.md`, since this +supersedes their quota hunk. + +## Verification + +Local product suite, typecheck and GUI build: **NOT RUN**, by operator instruction. Hosted CI on +the exact pushed head is the evidence. Two read-only `xai/grok-4.6` subagents reviewed the change: +one for blast radius, second callers and transport correctness, one adversarial pass over the +commit for type, runtime and layout-registration defects. + diff --git a/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md b/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md new file mode 100644 index 0000000000..3df412bb90 --- /dev/null +++ b/devlog/_plan/260911_l2_catalog_provider/020_flash_upstream_evidence.md @@ -0,0 +1,49 @@ +# 020 — what upstream actually publishes about Flash on the Responses endpoint + +`010` records the decision to leave `glm-5.3-flash` out of the Responses roster. This is the +evidence behind it, gathered from BigModel's own documentation on 2026-09-11, so the next unit does +not have to rediscover it. It changes nothing in the product. + +## The bar + +The issue makes Flash conditional on the domestic Responses endpoint supporting it, with verified +context, modalities and reasoning metadata, and asks for the upstream restriction to be documented +otherwise. The maintainer review says the same thing in the other direction: seed Flash only after +proof it works on that endpoint, and update the roster oracle in the same change. + +## What the vendor publishes + +| Page | What it says | +|---|---| +| `coding-plan/tool/codex.md` | Codex integrates at `https://open.bigmodel.cn/api/v1` with `wire_api = "responses"`. Its `models.json` example declares `glm-5.3` and `glm-5-turbo`. Flash appears nowhere on the page. Unchanged since it was checked on 2026-09-07. | +| `coding-plan/overview.md` | "所有套餐均支持 GLM-5.3、GLM-5.3-Flash." And: a call to `GLM-5-Turbo` or `GLM-4.7` is auto-switched to `GLM-5.3-Flash`. | +| `coding-plan/latest-model.md` | The plan supports GLM-5.3 and GLM-5.3-Flash for every tier, and lists the three protocol endpoints with Codex on `/api/v1`. The switching procedures it gives are for Claude Code (Anthropic wire) and Cline (Chat wire). There is no Codex procedure. | +| `coding-plan/faq.md` | The same plan-level roster: `GLM-5.3`, `GLM-5.3-Flash`. | + +## Why that is not the proof the issue asks for + +Availability is published per subscription; destinations are published per protocol. Every page that +names Flash is making a plan statement, every page that names `/api/v1` is making a protocol +statement, and no published page joins the two. The one page specific to this endpoint declares a +two-model catalog without Flash — the same oracle the preset was built from, and the same one the +roster test locks. + +The auto-switch line is the strongest single fact and it cuts both ways. Flash weights are already +reachable through this preset, because `glm-5-turbo` is in the shipped roster and the plan routes +that id to Flash. That is not evidence the endpoint accepts the literal id `glm-5.3-flash`, which is +what adding the roster entry would assert. + +Metadata is the harder half anyway. Seeding a model means declaring a context window, modalities, a +reasoning ladder and a default effort. Every Flash number in this repository comes from the Z.AI or +BigModel Chat rows or a Command Code page scrape, none measured on `/api/v1`, and the two rows +already disagree where they overlap: `glm-5.3` is `1_048_576` on Responses and `1_000_000` on Chat. +Copying the Chat numbers across would publish a catalog entry nobody has verified. + +## What would settle it + +One authenticated `POST https://open.bigmodel.cn/api/v1/responses` with `model: "glm-5.3-flash"` on a +Coding Plan key, plus whatever the vendor publishes for that model's Responses context window and +modalities. A live credentialed probe was outside this round; #4210's author declined it for the same +reason. Until then the accurate product statement is the one already in the docs: this preset ships +the roster the vendor documents for it. + diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b5ea45c4a3..acaa20a4f9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1319,6 +1319,7 @@ "z-handler-activation.test.ts": "images", "zcode-client.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", + "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 08da6d24ac..79e6a6bf91 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -2906,7 +2906,11 @@ function keyQuotaReaderForProvider(name: string, provider: OcxProviderConfig): K if (name === "deepseek" && isCanonicalDeepSeekBaseUrl(provider.baseUrl)) return fetchDeepSeekQuota; if (name === "cline-pass" && isCanonicalClineBaseUrl(provider.baseUrl)) return fetchClineQuota; if (isCanonicalOllamaCloudBaseUrl(provider.baseUrl ?? getProviderRegistryEntry(name)?.baseUrl)) return fetchOllamaCloudQuota; - if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; + // #4201: the Responses preset is the same domestic GLM Coding Plan subscription on the OpenAI + // Responses wire, so it reads the same monitor endpoint. Eligibility stays a name list AND the + // canonical-URL guard: the guard is what keeps BigModel's bare-key Authorization from reaching a + // lookalike host, so a same-named custom destination still dispatches nothing. + if (["zai", "glm", "glm-cn", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses"].includes(name) && isCanonicalZaiBaseUrl(provider.baseUrl)) return fetchZaiQuota; if (["minimax", "minimax-cn"].includes(name) && isCanonicalMinimaxBaseUrl(provider.baseUrl)) return fetchMinimaxQuota; if (name === "moonshot" && isCanonicalMoonshotBaseUrl(provider.baseUrl)) return fetchMoonshotQuota; if (name === "venice" && isCanonicalVeniceBaseUrl(provider.baseUrl)) return fetchVeniceQuota; diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d772205061..aaf20d5919 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1154,6 +1154,7 @@ "z-handler-activation.test.ts": "images", "zcode-client.test.ts": "providers", "zhipu-bigmodel-provider.test.ts": "providers", + "zhipu-bigmodel-responses-quota.test.ts": "providers", "zz-ci-api-usage-isolation.test.ts": "ci-workflows", "zz-ci-storage-policy-isolation.test.ts": "ci-workflows", "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows" diff --git a/tests/providers/zhipu-bigmodel-responses-quota.test.ts b/tests/providers/zhipu-bigmodel-responses-quota.test.ts new file mode 100644 index 0000000000..18557bd9e5 --- /dev/null +++ b/tests/providers/zhipu-bigmodel-responses-quota.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { clearProviderQuotaCache, fetchProviderQuotaReports, providerApiKeyQuotaMode } from "../../src/providers/quota"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +// Issue #4201: the BigModel Coding Plan Responses preset is the same domestic subscription as the +// Chat preset on a different wire, but keyQuotaReaderForProvider() admitted provider names +// zai/glm/glm-cn/zhipu-bigmodel-coding only, so providerApiKeyQuotaMode() answered "unsupported" +// and fetchProviderApiKeyQuotas() returned an empty list before any request was made. The +// destination check already accepted https://open.bigmodel.cn/api/v1; only the name list omitted +// the preset. These cases pin the eligibility AND the guards that make it safe: admitting a name +// must not admit that name on a destination BigModel does not serve, because the domestic monitor +// takes the API key in a bare Authorization header with no scheme. +const RESPONSES_ID = "zhipu-bigmodel-responses"; +const CANONICAL_BASE_URL = "https://open.bigmodel.cn/api/v1"; +const MONITOR_URL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +function keyProvider(overrides: Partial = {}): OcxProviderConfig { + return { adapter: "openai-responses", authMode: "key", baseUrl: CANONICAL_BASE_URL, apiKey: "bigmodel-secret", ...overrides }; +} + +function keyQuotaConfig(name: string, provider: OcxProviderConfig): OcxConfig { + return { defaultProvider: name, providers: { [name]: provider } } as OcxConfig; +} + +function quotaLimitsResponse(): Response { + return new Response(JSON.stringify({ + success: true, + data: { + limits: [ + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 30, nextResetTime: 1789000000000 }, + { type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 60, nextResetTime: 1789600000000 }, + ], + }, + }), { status: 200 }); +} + +beforeEach(() => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-bigmodel-responses-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(opencodexHome); +}); + +describe("BigModel Responses preset quota eligibility", () => { + test("the preset is probe-eligible on the destination its own registry entry ships", () => { + // Anchored to the registry rather than a hand-typed URL: if the preset's destination ever + // moves, this fails instead of silently proving eligibility for a URL nobody serves. + expect(getProviderRegistryEntry(RESPONSES_ID)?.baseUrl).toBe(CANONICAL_BASE_URL); + expect(providerApiKeyQuotaMode(RESPONSES_ID, keyProvider())).toBe("probe"); + // The Chat preset keeps its existing eligibility; this is an addition, not a swap. + expect(providerApiKeyQuotaMode("zhipu-bigmodel-coding", keyProvider({ + adapter: "openai-chat", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + }))).toBe("probe"); + }); + + test("eligibility still requires a canonical destination and a key auth mode", () => { + for (const provider of [ + // A same-named custom provider pointed somewhere else. + keyProvider({ baseUrl: "https://custom.example.test/api/v1" }), + // The pay-as-you-go endpoint, which is not the Coding Plan subscription. + keyProvider({ baseUrl: "https://open.bigmodel.cn/api/paas/v4" }), + keyProvider({ disabled: true }), + keyProvider({ authMode: "forward" }), + keyProvider({ authMode: "oauth" }), + ]) { + expect(providerApiKeyQuotaMode(RESPONSES_ID, provider)).toBe("unsupported"); + } + }); + + test("the preset probes the domestic monitor endpoint with the bare-key Authorization", async () => { + const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = init?.headers as Record | undefined; + seen.push({ url: String(input), authorization: headers?.Authorization, redirect: init?.redirect }); + return quotaLimitsResponse(); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(keyQuotaConfig(RESPONSES_ID, keyProvider()), true); + + expect(result.reports).toHaveLength(1); + expect(result.reports[0]?.provider).toBe(RESPONSES_ID); + expect(result.reports[0]?.source).toBe("zai:quota-limit"); + expect(result.reports[0]?.quota).toMatchObject({ fiveHourPercent: 30, weeklyPercent: 60 }); + expect(seen).toHaveLength(1); + expect(seen[0]?.url).toBe(MONITOR_URL); + // No "Bearer " prefix: open.bigmodel.cn answers a Bearer header with an auth error (#1168). + expect(seen[0]?.authorization).toBe("bigmodel-secret"); + expect(seen[0]?.redirect).toBe("error"); + }); + + test("a same-named custom destination dispatches no quota request at all", async () => { + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + seen.push(String(input)); + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports( + keyQuotaConfig(RESPONSES_ID, keyProvider({ baseUrl: "https://attacker.example/api/v1" })), + true, + ); + + // The bare key must never travel to a lookalike host, so the guard has to refuse before + // the request, not after reading a response. + expect(result.reports).toEqual([]); + expect(seen).toEqual([]); + }); +});