Updated: 2026-08-01 · Branch: feat/least-time-route (base origin/dev) · Version: V1 released; post-release development open
- 2026-07-23 risk note: under sustained parallel load the e2e console-readiness probe (30s) intermittently times out even though the dev server logs Ready in ~300ms — full verify:features runs can flag unrelated console-suite features. Batch-level flakes cleared on a quiet-machine rerun (25/25). Test-infra follow-up candidate: raise/warm the readiness probe; not a feature regression.
Core Platform Security; Provider Model Management; Virtual Model Routing; Gateway Protocol Execution; API Key Access and Limits; Usage and Activity; Worker Model Operations; Console Core; Delivery Quality; API Key Integration Guidance.
- Console pages: Overview, API Keys, Providers, Virtual Models, Activity, Usage, Limits, Playground. Tailwind v4 with the design tokens as CSS variables; light/dark toggle persisted, system-default; page state (selection, filters, paging, open dialog) lives in the URL.
- Public protocols: Chat Completions, Responses, Messages, and model discovery.
- Worker jobs:
model_refresh,provider_connection_probe,price_sync,provider_quota_probe. - Database: PostgreSQL 18.4 Alpine; migrations start from
0001_core_baseline.sqland new migrations are expected post-V1. - Deployment: Docker Compose with two containers — one multi-role app (
all: migrate then Gateway/Console/Worker) plus PostgreSQL.
-
2026-08-01 (least-time-route-strategy CI fix, PR #70 follow-up): The two restart-style E2E cases (
:475/:576intests/e2e/least-time-routing.e2e.case.ts) flakedECONNREFUSEDon CI (3/3 retries) and ~1/6 locally. Root cause: the secondstartGatewayProcessin each test reused the same port as the first, sowaitForGateway's readiness probe could be answered (a false-positive 200, 45ms after stop resolved) by a still-open undici keep-alive connection to the not-yet-exited old process rather than the new one, letting the test proceed before the new process had actually bound the port. Fix: both restart-phasestartGatewayProcesscalls now use a freshgetFreePort()result (restartPort) instead of reusingport, so the probe can never be answered by a stale connection to the dying instance. No change totests/support/gateway-process.tsor any other test. Verified: 12/12 consecutive--grep "restart reseeds|unmeasured candidate"runs EXIT=0 (previously ~1/6 failure rate); full spec 5/5 EXIT=0;pnpm run lintEXIT=0. -
2026-08-01 (least time route strategy,
least-time-route-strategy, 39th feature): Added a sixth route strategy —least_time, ordering a Virtual Model's candidates by latency observed from real Gateway traffic — across eight TDD milestones onfeat/least-time-route(worktree, based onorigin/dev). This session executed M7-M8; M1-M6 (migration0007_least_time_route_latency.sql, the domainleast_timehandler with EWMA tie-bucketing + exploration, thegateway-route-latencystats singleton, fallback-chain sampling wired into both protocol pipelines,main.tsstart()/stop() lifecycle wiring, and Console strategy enumeration) landed in prior sessions and were already green (676/676 unit, build 13/13). M7: addeddelay_ms(json branch) andfirst_byte_ms(stream branch) timing controls totests/support/fake-provider.ts, and a five-scenariotests/e2e/least-time-routing.e2e.case.ts. Red confirmed by temporarily stashing the fake-provider change: 2/5 failed deterministically on real-elapsed-time assertions (77ms/60ms observed against a required >=350ms — delay silently ignored), the other 3/5 already passed since they only exercise the already-green M1-M6 logic. Green after restoring the change: 5/5 (12.3s). The "converges on the faster candidate" and "ordered by first-byte latency, not stream length" scenarios warm each candidate through its own single-candidatefixedroute first —route_latency_statskeys onprovider_model_id, so a sample earned under one route policy carries over when the sameprovider_model_idis reused as aleast_timecandidate — keepingdelay_ms/first_byte_msgenuinely load-bearing (an independent wall-clock assertion additionally proves the fake-provider delay is honored) rather than deriving the ordering from a seeded fixture. "A failing fastest candidate falls back to the next-fastest one" seedsroute_latency_statsdirectly (the same technique already used forrate_limit_windows/budget_periodsE2E fixtures), since a permanently-failing candidate can never earn a real success sample. "A gateway restart reseeds the latency ordering" and "an unmeasured candidate is explored" both stop/start a real Gateway child process on the same database and port — pollingroute_latency_statsuntil the flushed row lands before restarting — then assert the very first post-restart request is served directly, with zero requests ever reaching a newly added, configured-order-first, never-measured distractor candidate. Regression:virtual-model-routing.e2e.spec.ts+weighted-routing.e2e.spec.ts+tag-routing.e2e.spec.tsEXIT=0, 20/20. M8:docs/ARCHITECTURE.md(strategy list + restart-survives-in list),docs/PRODUCT.md(routing section),README.md, anddocs/README.zh-CN.mdeach gained oneleast_timemention matching their existing style;feature_list.jsongained theleast-time-route-strategyentry (verification string usesplatform-security.unit.test.tsas the real aggregate entry point for the migration-manifest check, sinceplatform-foundation.unit.test.tsis not a file —platform-foundation.unit.case.tsis imported byplatform-security.unit.test.ts). Final gates: the feature's own verification string run verbatim EXIT=0 (unit 126/126, e2e 18/18);pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 676/676 across 36 files — unchanged from the M1-M6 baseline since M7-M8 added no new unit-level cases, build 13/13);pnpm run verify:featuresEXIT=0 — all 37 passing feature(s) re-verified on the first try, no per-feature fallback needed (unit batch 9.4s, e2e batch 410.5s), zero regression. Deviations from the plan's literal env line (GATEWAY_LEAST_TIME_EXPLORE_PERCENT"0"by default,"100"only for the explore scenario): the ordering algorithm never promotes a cold candidate ahead of a warm one while explore percent is 0, so two candidates in the same route policy can never both go organically warm under a constant 0% — routed around with the cross-policy shared-provider_model_idwarm-up technique above (explore percent stays 0 throughout, as specified); the fallback scenario's failing candidate usesmode=unsupported-parameterrather thanmode=error, becausemode=error's 503 gets retried by the circuit breaker's retry policy (an early run observed 3 requests instead of 1), whileunsupported-parameter's 400 falls back after exactly one attempt, matchingweighted-routing.e2e.case.ts's existing fallback-test precedent. Not pushed and no PR opened — left for the reviewing session. -
2026-07-31 (weighted route strategy,
weighted-route-strategy, 38th feature): Added a fifth route strategy — stateless weighted-random routing over per-candidate two-decimal percentages that must sum to exactly 1.00 — in six TDD milestones onfeat/weighted-route-strategy(worktree, based onorigin/dev). M1: migration0006_route_policy_candidate_weights.sqladdsroute_policy_candidates.weight numeric(3,2), nullable, no default, range-checked 0-1; NULL means the policy's strategy does not route by weight,0.00is a real fallback-only configuration. Three-way pin (SQL,shippedSqlMigrationschecksum, manifest test) confirmed red (5 loaded vs 6 expected) then green. M2: domain gains"weighted"onRoutePolicyStrategy,RouteCandidate.weight,RouteReason.selectedWeight, and a registry handler (orderWeightedCandidates) doing a successive cumulative-weight draw without replacement — a zero-weight candidate can never be drawn while positive weight remains, and once only zero-weight candidates are left they append incandidateOrder. The structural guard (nostrategy === "inpackages/domain/src/index.ts) still holds. M3:normalizeRoutePolicyCandidateWeightsvalidates each weight (/^(?:0(?:\.\d{1,2})?|1(?:\.0{1,2})?)$/) and sums on integer hundredths to avoid float error, throwingroute_policy_weight_invalid/_missing/_sum_invalid; wired throughlistRoutePolicies,writeRoutePolicyCandidates, and the virtual-models API route viareadAlignedTextValues(notreadTextValues, which drops blanks and misaligns candidates). M4:GatewayRouteCandidateSnapshotand the snapshot SQL/mapping carryweight; the twoselectRouteAttemptscall sites needed zero changes (snapshot passed through). Newweighted-routing.e2e.case.tsproves a 1.00-weight candidate takes all traffic deterministically and a failing full-weight candidate falls back to the kept zero-weight one (2 fallback_events);tag-routing.e2e.spec.tsregressed clean (7/7) after the seed-helper signature change. Backfilledweight: nullon 7GatewayRouteCandidateSnapshottest fixture factories per the plan's red line, thoughtests/is not covered by any tsconfig inpnpm run typecheck/typecheck:scriptsin this repo today — applied as compliance, not because a type error was observed. M5:dialogs.tsxgains an index-alignedcandidateWeightsfield per selected candidate (hidden-input fallback for non-weighted strategies, matching the existingcandidateTagspattern) plus weighted-specific CANDIDATES/SELECTED copy;detail.tsxgains a WEIGHT column beside the TRAFFIC meter. New console e2e case saves a 0.75/0.25 split, refuses a bad sum inline, re-displays stored weights on edit, and holds no horizontal overflow at 1280 or 390. Every milestone committed on green. Final gates:pnpm run verifyEXIT=0 (lint clean afterlint:fixon two formatting diffs, typecheck 13/13, unit 649/649 across 35 files, build 13/13); the feature's own verification string EXIT=0 (unit 63/63, e2e 11/11 — weighted-routing 3 + virtual-model-routing 8);tests/e2e/tag-routing.e2e.spec.tsregression EXIT=0 (7/7);pnpm run verify:featuresEXIT=0 — all 37 passing feature(s) re-verified (unit batch 8.1s, e2e batch 346.8s), zero regression. No deviation from the approved plan beyond one cosmetic field-order placement inRoutePolicyCandidateRow. Not pushed and no PR opened yet — left for the reviewing session per the execution brief. 2026-08-01 UI follow-up: the weight field became a"use client"WeightInput(apps/console/src/app/_ui/virtual-models/weight-input.tsx) that restores the last valid draft on everyonInputunless the new value is a prefix of a valid two-decimal weight, so a third decimal digit or a stray letter never reaches the field client-side; TDD red-to-green (unit ENOENT on the missing module then 46/46, e2etoHaveValue("0.12")red against"0.12222"thenweighted-routing.e2e.spec.ts3/3),pnpm run verifyEXIT=0. 2026-08-01 streaming e2e follow-up: added two real-gateway streaming cases toweighted-routing.e2e.case.tspinning the description's first-byte-only fallback contract for streams — a candidate refused before its first byte falls back to the zero-weight candidate (healthy 200 stream, 2 fallback_events), and a candidate that dies mid-stream after its first chunk is never replayed elsewhere (the healthy zero-weight candidate sees zero requests); weighted spec 5/5,pnpm run verifyEXIT=0, feature verification string e2e 13/13. -
2026-07-31 (Playground HEADERS rows): TDD red-to-green in two reds. The rewritten unit case failed 14 of 19 with the old helpers in place, and the rewritten E2E case timed out at 240s against a console still rendering the free-text area (page snapshot showed
textbox "Request headers"and no Add header button). After the change the feature verification passed EXIT=0 (unit 19/19, e2e 1/1 in 11.8s), the six suites that assert againstplayground.tsxsource passed 132/132,pnpm run verifypassed EXIT=0 (lint clean, 13 typechecks, 621/621 unit across 34 files, 13 builds), andpnpm run verify:featurespassed EXIT=0 re-verifying all 35 passing features in one optimized run (unit batch 7.2s, e2e batch 334.1s). -
2026-07-30 (deterministic fallback activity timestamps): fixed the
usage-and-activityunit and E2E fallback-storage cases to pass explicit fixed completion timestamps instead of combining a fixed 2026-07-05 start with the real current time. This removes the date-triggeredrequest_activity.latency_msinteger overflow that failed CI after 2026-07-29. Focused verification passed 17/17 unit tests and the 12-test Usage and Activity E2E suite;pnpm run verifypassed lint, 13 package typechecks, script typecheck, 574/574 unit tests, coverage, and all 13 builds;pnpm run verify:featurespassed its optimized unit batch and 150-test E2E batch, re-verifying all 33 passing features without fallback. -
2026-07-29 (
.env.exampleconfiguration audit): reorganized the example into shared endpoints/security, Docker Compose, localinit.sh/pnpm dev, tests, Gateway, Worker, and Provider OAuth sections. Preserved the operator's in-progressPOSTGRES_PORTaddition and stale smoke-key removal. Every setting with a built-in default is now a commented example, optional empty assignments no longer erase lower-precedence values after copying to.env.local, and the hostDATABASE_URL/TEST_DATABASE_URLrelationship toPOSTGRES_PORTis explicit. Added the missing supported Gateway breaker/retry/stream/cache knobs, Worker duration/retention knobs, common logging/pool/provider timeout knobs, local hosts/bootstrap, and optional OAuth client-id overrides; deliberately omitted internal/build/test-harness variables and the legacy retention alias. Compose now forwards the documented common/Gateway/Worker/OAuth overrides into the app container so Docker configuration is not merely decorative. TDD red-to-green: platform-security passed 36/36; Compose expansion proved Shell overrides for log level, Gateway retries, and Worker max duration; focused syntax/style/JSON/diff checks passed. Full regression was not run. -
2026-07-29 (three startup paths, live verification):
./scripts/deploy.shinitially exposed a real context collision after.env.localbecame visible to Compose: its hostDATABASE_URL(127.0.0.1) replaced the container-network default and migration exitedECONNREFUSED. Docker now reads the independently overridableCOMPOSE_DATABASE_URL, defaulting to thepostgres:5432service, while host processes retainDATABASE_URL. The second live check found Turbo strict env filtering values loaded by rootpnpm dev; dev now uses--env-mode=loose, which is required to forward the complete resolved environment to all three app tasks. Final live results: deploy.sh healthy on Console 3001, Gateway 4001, PostgreSQL 55433; init.sh Shell overrides healthy on 13002/14002 with Worker started; pnpm dev Shell overrides healthy on 13003/14003 with Worker started and stopped cleanly. Both local runs were stopped and their ports released; the Docker stack remains healthy. Focused platform-security tests passed 35/35; targeted type/style/config checks passed; no full regression was run. -
2026-07-29 (unified environment precedence): every repository startup path now resolves variables as Shell >
.env.local>.env> code defaults.deploy.shalways passes.envand conditionally passes.env.localafterward to Compose; rootpnpm devnow runs through the same loader already used byinit.sh; and the non-Docker public Gateway URL derives from the effectiveGATEWAY_PORTunlessGATEWAY_URLis explicitly set. TDD red-to-green and focused verification only, per operator request: platform-security tests passed 35/35; real deploy-script argument capture covered.env.localpresent and absent; Shell syntax, Compose config and Shell override expansion, Biome, config/script typechecks, JSON, and diff checks passed. Fullverifyandverify:featureswere not run. -
2026-07-29 (published Gateway URL derivation): Compose now derives the Console-facing
GATEWAY_URLfromGATEWAY_PORT(4567expanded tohttp://127.0.0.1:4567in the config check), so Playground model discovery reaches the branch's published Gateway port instead of a stale hard-coded4000. An explicitGATEWAY_URLremains authoritative for reverse proxies or external hosts. TDD red-to-green and focused verification only, per operator request: platform-security unit tests passed 34/34; Compose config expansion passed for derived and explicit URLs; Shell syntax, Biome, JSON, and diff checks passed. Fullverifyandverify:featureswere not run. -
2026-07-29 (branch-scoped Compose projects): Docker deployment identity now follows the checked-out Git branch instead of the worktree directory.
mainstays on projectllmingress; every other branch usesllmingress-<normalized-branch-name>(feat/console-ui-redesign->llmingress-feat-console-ui-redesign), which isolates its containers, network, PostgreSQL volume, and worktree-local.env. Detached HEAD deployment is refused rather than writing into an ambiguous stack. The existing recreate/remove-orphans recovery applies only inside the selected branch project. Focused verification only, per operator request: platform-security unit tests passed 34/34, the deployment test executed the real script against mocked Git/Docker for both branch shapes, Shell syntax and both Compose project configs passed, and the touched test passed Biome. Fullverifyandverify:featureswere not run; no second live stack was started because the existing stack owns the default ports. -
2026-07-28 (Docker deploy repair):
./scripts/deploy.shnow forces Compose container recreation and removes orphan containers while preserving the named PostgreSQL data volume. This repairs a reachable stale-network failure where PostgreSQL remained healthy but had no Compose network attachment, causing the app migration to exit withgetaddrinfo ENOTFOUND postgres. Verification was intentionally focused: platform-security unit tests passed 33/33 withTEST_DATABASE_URL; PostgreSQL was then forcibly disconnected from the project network and the updated deploy script recreated both containers, restored thepostgresDNS alias, preserved all three applied migrations (Applied 0 migrations; skipped 3), and returned Gateway readiness plus Console HTTP 200 with Worker started. Fullverifyandverify:featureswere not run at the operator's request. -
2026-07-28 (thirtieth pass): Final PR #56 regression repaired the shared Providers mobile overflow by stacking the master/detail grid below
lgand allowing the Models filter/paging row to wrap; the provider detail no longer forces a mobile left gutter. Virtual Model name conflicts now reportfield: name, so the refused input receivesaria-invalid; capability mismatch errors remain selectable and are explained only on save. Module navigation persists the module being left before navigation and lets the durable localStorage snapshot win over stale reused masthead state; the Overview7d + Hideand Usage30dflow passed five consecutive focused runs. Regression fixtures were aligned with the intentional API-key-first Playground model lookup and newest-first list ordering, while Toast assertions now exclude Next.js's route announcer. Final gates:pnpm run verifypassed (lint, 13 package typechecks, script typecheck, 571/571 unit tests, 13 builds);pnpm run verify:featurespassed its optimized unit batch (8.7s) and 150-test E2E batch (417.0s), re-verifying all 33 passing features without fallback. Development services were stopped after verification. -
2026-07-28 (twenty-ninth pass): Repaired the
init.shstartup gate after running it in the canonical redesign worktree. Biome first refused 19 accumulated formatting/import-order errors, then TypeScript foundsetApiKeyLimitsEnabledreadingrowCountfrom the deliberately narrowerConfigPublishQueryResult; the update now usesreturning idandrows.length, preserving the API-key-not-found refusal inside the configuration transaction. Three stale unit contracts were aligned with already-shipped behavior: the shrinkable Provider master column, required Limits enforcement, and transaction-source matching for property shorthand. Repository formatting was normalized and the two remaining false-positive template-placeholder lint warnings were removed../init.shthen passed lint, all 13 package typechecks, script typecheck, 571/571 unit tests, and all 13 builds; Gateway listens on 127.0.0.1:4000, Console is ready on 127.0.0.1:3000, and Worker reports started. -
2026-07-28 (twenty-eighth pass): Audited every Console backend API action for transaction ownership. Provider API-key save/update now stores connection settings and the quota-probe switch in the same configuration transaction; OAuth complete/update does the same for token and connection settings. Device OAuth calls the upstream first, then replaces the previous pending row and inserts the new one atomically, so an upstream or insert failure cannot delete the prior attempt. Login reads the admin credential and creates the session in one transaction, setup relies on its atomic unique insert instead of a pre-read, and multi-query Playground activity detail now reads one transaction snapshot. Network requests remain outside database transactions and Provider probe/model-refresh Jobs are still enqueued only after the business transaction commits. Added source contracts plus database rollback cases for API-key settings, OAuth settings, and device-pending replacement. No automated or browser verification was run at the operator's request; manual verification is pending.
-
2026-07-28 (twenty-seventh pass): Providers, API Keys, Virtual Models, Limits (through its API Key rows), and Activity now default to
created_at DESC, id DESCin their database-owned list queries, replacing provider-key/name ordering and Activity's started-time-first ordering. Added a source contract for all four query owners and a database-backed Console E2E that deliberately reverses names and Activity start times to prove creation time owns the result order. No automated or browser verification was run at the operator's request; manual verification is pending. -
2026-07-28 (twenty-sixth pass): Removed the redundant “the template fixes the wire protocol and default base url” note from the Add Provider template-group row. No automated or browser verification was run at the operator's request; manual verification is pending.
-
2026-07-28 (twenty-fifth pass): Masthead module navigation now remembers sanitized durable URL state per module instead of linking every module back to its bare default route. Overview keeps
windowand Getting started visibility; Usage keeps its window; Activity keeps all filters/search/page; Providers, Virtual Models, and API Keys keep their primary selection and filters; Limits keeps its filters. The state is allowlisted before both storage and restoration, so dialogs/drawers, mutation drafts, OAuth callback/user-code values, Toasts, Activity request drawers, Limits drawers, and every Playground value (especially the plaintext API key) are excluded. A Clear navigation records an empty query and therefore replaces the remembered state with defaults. Storage failure degrades to in-tab memory. Added pure contracts for every module, unsafe/transient exclusion, masthead wiring, and a navigation E2E covering Overview7d + Hideplus Usage30d. No automated or browser verification was run at the operator's request; manual verification is pending. -
2026-07-28 (twenty-fourth pass): Provider Edit, Enable/Disable, Delete, and Refresh models now render in one non-wrapping action group. The dedicated refresh row existed only to contain its former inline refusal; after refresh failures moved to the shared red Toast, that row had no remaining purpose and was removed. The provider identity row may still wrap the complete action group below the identity on a narrow viewport, but the four buttons remain together in one row. Updated the layout source contract and product/tracker wording; no automated or browser verification was run at the operator's request, and manual verification is pending.
-
2026-07-28 (twenty-third pass): Unified button-only idempotent failures with the existing Toast path.
MutationFormnow has an expliciterrorPresentation="toast"mode that announces the server error or network fallback throughToastHost; the new red tone uses the danger border, red message text, androle=alert, while success remainsrole=status. Provider Refresh models, refresh Retry now, connection Re-check, and Overview Re-check opt in. The Playground's duplicate private Toast was removed in favor ofToastHostfor successful responses, Gateway errors, and network errors, and Copy failure changed from amber to the same red error tone. Input-bearing create/edit/limits forms, missing Playground fields, authorization polling, and destructive confirms remain inline because their errors require correction in that context. Unit/source contracts and the two Provider E2E cases now require a red Toast and no inline detail alert. No automated or browser verification was run at the operator's request; manual verification is pending. -
2026-07-28 (twenty-second pass): Fixed Provider selection as a complete context boundary rather than only clearing model filters. A failed
MutationFormwas reused by Next.js when another Provider occupied the same detail slot, so its inline refusal remained visible; both the detail and dialog subtrees now remount by Provider id. Provider row links are built from a fresh query and carry only the model page-size preference, dropping the previous Provider's model search/page, dialogs, connection id, refresh marker, OAuth authorization state/errors/draft values, template choice, and transient feedback. Added a direct URL-state unit contract and extended the editor E2E with the real “refresh without credentials, then switch Provider” regression. Per the operator's request, automated and browser verification were not run; manual verification is pending. -
2026-07-28 (twenty-first pass): Unified URL-filter display state after client navigation. Activity's Clear already removed every query parameter and queried the default Last 24h window, but its uncontrolled selects and search input kept the previous DOM values because Next reused the form. Activity, Virtual Models, API Keys, Limits, and Provider Models now key each native filter form only by the URL values its controls display, so relevant parameter changes remount it without unrelated detail navigation discarding an unapplied draft. The shared
SyncedSearchInputandSyncedSelectalso adopt changed value props instead of retaining their initial local state. Source contracts cover all five native filter forms and both shared controls; the Activity E2E now clears all seven populated controls through the real Clear link and expects the empty defaults plusLast 24h. No automated or browser verification was run at the user's request, and manual acceptance is pending. -
2026-07-28 (twentieth pass): Gateway no longer overwrites a Provider's
Access-Control-Expose-Headersresponse value with its browser CORS list. The Provider value remains first and unchanged; Gateway appends only missing Gateway-owned header names using case-insensitive de-duplication. With no allowed browser origin, the Provider value passes through untouched, and every other Provider response header keeps the existing behavior. Unit and end-to-end contracts cover a Provider-specific exposed header, one overlapping mixed-case name, and the Gateway request-id addition; no automated or browser verification was run at the user's request, and manual acceptance is pending. -
2026-07-28 (nineteenth pass): Restored the backend Virtual Model capability contract removed by
d25dfec2, without restoring the picker gray-out. Candidates remain selectable when their context window, modalities, function-calling, reasoning, or output limits differ; create and update now validate at save time and atomically refuse known differences. The inline error names every differing field plus both candidate labels and exact values, while unknown capability values remain allowed. Gateway validation was restored for any incompatible route already stored during the regression window. Unit, DB-integration, and Console E2E contracts were updated; no automated or browser verification was run at the user's request, and manual acceptance is pending. -
2026-07-28 (eighteenth pass): The Limits drawer now renders Save rules, Disable limits, and Delete rules inside the same responsive action row instead of placing Save rules on a separate line. Their existing actions and visibility rules are unchanged. A source layout contract records the grouping; no automated or browser verification was run at the user's request, and manual acceptance is pending.
-
2026-07-27 (seventeenth pass): Confirmed that
fd80536balready removed theDisable limits insteadaction and its old “pause without losing rules” note from the Delete limit rules dialog; the screenshot was an already-open pre-change client render. One E2E assertion still expected that retired wording, so it now explicitly requires the alternative link to be absent and the current note to say deletion also switches limits off. No automated or browser verification was run at the user's request; close and reopen the dialog or reload the page to discard the stale render. -
2026-07-27 (sixteenth pass): Corrected the remaining Provider error-layout defect after another manual screenshot. The earlier fixes made the error shrink and stopped the entire master-detail view from scrolling, but
Refresh modelswas still a MutationForm inside the same right-hand flex row as Edit / Enable / Delete; once its inline error appeared, the form became tall and pushed its own button onto a second line. The title row now contains only provider identity and the three ordinary actions. Model refresh has a dedicated two-column row below it: the inline refusal owns the flexible column andRefresh modelsremains in the right column on the same baseline. The source layout contract was updated, but no automated or browser verification was run at the user's request; manual acceptance is pending. -
2026-07-27 (fifteenth pass): Corrected the API key LIMITS regression introduced by
bae69ed2, which deliberately persisted rules whilelimits_enabled=false. Limits are now an optional all-or-nothing section: create and edit routes do not parse limit fields at all when the switch is off; database create ignores submitted rules, update/disable removes any existing rows, and the standalone Limits save both requires the complete five-rule set and enables enforcement in the same transaction. When enabled, budget amount and period, RPM, TPM, tokens/request, concurrency, and enforcement are all required; incomplete or invalid sets are refused without changing stored rules. The API key editor and Limits drawer now state that requirement instead of advertising blank fields as unlimited. Unit/source contracts and E2E expectations were updated, but no automated or browser verification was run at the user's request; manual acceptance is pending. -
2026-07-27 (fourteenth pass): Correction to the eleventh-pass Provider error-layout fix after manual validation.
cdbb3b6acorrectly made the inline error and compact action forms shrink, but leftoverflow-x-autoon the whole Providers master-detail grid. That made the complete left-list/right-detail composition horizontally scrollable, so the user could still see the page shifted to the right even though the alert itself wrapped. The outer grid no longer scrolls and its 344px master track may shrink; only the deliberately wide Connections and Models tables keep their local horizontal scrollers. The running Next process and commit were both confirmed under/Users/zhouxiaoxiao/Github/LLMIngress-console-redesign. The layout contract was updated, but no automated or browser verification was run at the user's request; manual acceptance is pending. -
2026-07-27 (thirteenth pass): Playground model discovery no longer exposes the Console-wide Virtual Model list before a key is supplied. The fallback was introduced by
82090587on 2026-07-25:/v1/modelswas key-scoped, but an absent, changed, rejected, or unreachable key fell back to every route the Console could read (and a changed key could temporarily keep the previous key's models). The selector now starts empty and disabled, clears immediately whenever the key changes, and enables only after the Gateway successfully returns that key's grants; rejected requests, network failures, and keys with no grants remain empty with distinct guidance. The existing Console workflow unit/E2E contracts and product docs were updated, but no automated or browser verification was run at the user's request; manual acceptance is pending. -
2026-07-27 (twelfth pass): Virtual Model candidates no longer have to carry identical capability metadata. The dialog now disables a candidate only when it does not serve the selected endpoint protocol; differences in context window, modalities, function calling, reasoning, or output limits do not gray it out. Route-policy writes no longer reject those differences, and Gateway request pre-checks retain only values shared by every candidate, leaving differing or unknown fields to Provider execution and fallback. The UI restriction was introduced by
3565b18fon 2026-07-27; the underlying save/runtime equality contract began incf1ed612on 2026-07-11. Tests and product wording were updated, but no automated or browser verification was run at the user's request; manual acceptance is pending. -
2026-07-27 (eleventh pass): Providers compact-action error layout —
MutationFormroots now shrink inside flex/grid parents and error text wraps even for unbroken values; the Provider heading, refresh-failure retry row, and connection action row wrap instead of widening the master-detail scroller. Added a focused layout contract and a real disabled-Provider regression covering bothRefresh modelsandRe-check. The focused unit suite (32/32) and regression E2E (1/1) passed before the user asked to stop further automated/browser verification; final visual acceptance is intentionally left to the user's manual check. -
2026-07-27 (tenth pass): PR #56 P1 review fix — creating an API key with ENABLE LIMITS off silently discarded every ceiling the form carried. The Console route replaced the rules with an empty list, and
createApiKeyWithSettingsindependently skipped the rule write whenlimits_enabledwas false. Both now preserve the submitted rules;limits_enabledcontrols enforcement only, matching the architecture and product invariants. A unit guard covers both ownership layers, andapi-key-editor.e2ecreates a disabled key with custom weekly budget/RPM rules and reads them back from PostgreSQL. Focused unit 8/8 and browser E2E 1/1 passed. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13 + scripts, unit 551/551, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 8.8s, E2E 321.2s), zero regression. -
2026-07-27 (ninth pass): Console UI redesign — the settings an authorization carries. Starting one stores the label and priority the operator typed on the pending connection and redirects to a screen that names it by
providerOAuthId; that screen's form is submitted again when the provider confirms, and it was rendering its own defaults, so completing an authorization wrote label null and priority 100 over the answers given on the way in. The routing order changed silently. The obvious repair — resolve the connection fromproviderOAuthId— does not work:listProviderOAuthMetadataselectswhere completed_at is not null, so a pending row is not in the list the dialog reads (model.tshas a branch forauthorization pendingthat the data never reaches). The values come back on the URL instead, inproviderOAuthLabelValueandproviderOAuthPriorityValue— parameters the start redirect has always written and nothing has ever read.ConnectionIdentityFieldstakes the two values rather than a connection now, so each screen names where its defaults come from. A first draft of this fix also carriedenabledandquotaProbeEnabledthrough, on the strength of a probe that seededquota_probe_enabled = falseon a pending row: that state is not reachable, because the pre-start form has no state fields and the column defaults to true, so the fallback already matched what is stored. Removed rather than shipped — a fix for an unreachable state is a claim that cannot be checked.provider-oauth-pending-settings.e2eseeds a pending authorization, opens the URL the start redirect builds, and holds both the rendered values and what the poller's submit stores. -
2026-07-27 (eighth pass): Console UI redesign — five reachable defects from an external review, four of them the same mechanism. Credentials could not be added at all. The add link carried
connection=newas a placeholder, and the guard added a round earlier — "a link that names a connection has to find it" — treats any value it cannot resolve as a connection that is gone, ahead of the branch that opens the credential form. Every provider's+ Add keyand every subscription's+ Authorize tokenopened "Connection not found", so a fresh install could not finish its first step and an existing one could not rotate or add a second key. Adding names no connection: the parameter is dropped rather than set to a placeholder. A draft outlived the dialog it belonged to.closeHrefcleared the candidate parameters but noteditor_name,editor_description,protocoloreditorStrategy, andbuildHrefcopies the rest forward, so closing one model's editor and opening another's showed the first model's name and strategy — and saving wrote them. The API key editor already cleared its own draft; the route editor now does too. Removing the last candidate restored every candidate.?candidates=reads as absent, and absent means "the URL has not said", so the editor fell back to the stored route: clearing the list to rebuild it appended instead.candidate-paramsnames the empty set, the shape the grants picker already uses. A refused route left a rename committed.updateWithRoutewas two publishes, so a protocol or capability refusal surfaced as "could not be saved" over a model that had already been renamed — every client still sending the old name got a 403 with nothing on screen saying why.updateVirtualModelWithRoutepublishes once;updateVirtualModelWithClientandupdateRoutePolicyWithClientare the halves it composes. The blocked-delete dialog offered a way out that is refused for the same reason. Disabling a provider a route still names returns the same 409 as deleting it, so "or disable the provider" sent the operator into a second refusal; the note now names the remedy that works (switch its connections off) and the disable dialog says so before the click rather than after, which is whygetProviderDependencyImpactis now read for that confirm too. Found while fixing it: that impact named routes bydescription, which is not what the Virtual Models list is keyed by — the operator was told to go edit a string no screen shows. It names them byname; the assertion that pinned the description was updated with the reason. Gates:pnpm run verifyEXIT=0 (unit 550/550, build);pnpm run verify:featuresEXIT=0. -
2026-07-27 (seventh pass): Console UI redesign — two design-fidelity fixes on the same branch. The Usage page's cost sources collapsed two different dashes into one. The prototype's four rows are
provider $14.02,estimated $9.39,reconciled $0.00 · 0 reqsandunavailable / plan — · 5,463 reqs: a priced source that produced nothing costs $0.00, which is an amount, while plan traffic records no metered cost at all, which is not an amount of zero. The panel renderedformatCost(null)for any absent source, soreconciledread— · 0 reqs, and it labelled the last row with the raw enumunavailable, dropping the half of the label that says what that dash means.COST_SOURCESnow carries a label and ameteredflag per row. Almost no deployment produces a reconciled cost, so the wrong form was what nearly every install saw. The two one-time credential pages fetched a webfont from Google.standaloneThemeHeademitted afonts.googleapis.compreconnect and stylesheet while the console proper self-hosts throughnext/font, so the only two pages that display a plaintext credential were also the only two making a third-party request — and the only two that lose their faces on an air-gapped install. The head now emits the theme bootstrap alone; the token stack already named'Open Sans', system-ui, sans-serif, so a machine that has the face still uses it and one that does not renders immediately instead of waiting on a request it cannot make. Self-hosting the two woff2 files was the alternative and was not taken: it adds build assets to keep in sync for two utility pages. A unit guard holds that neither page contains an off-host URL and that the head has no<link>at all. A note on my own verification: the first red check for the cost-source fix was invalid — a blanket string replace during the revert also hit an unrelated{label}in another component, so the Usage page failed to render and the test failed for the wrong reason. Redone as a targeted revert of only the two things under test, it fails ongetByText('$0.00 · 0 reqs')and passes when restored. Same trap as the fabricatedeligible: falsefixture the round before: a red or a green that comes from the wrong cause proves nothing. Gates:pnpm run verifyEXIT=0 (unit 550/550, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 7.7s, e2e 304.0s), zero regression. -
2026-07-27 (sixth pass): Console UI redesign — a false claim of mine, and the panel built on it. FILTERED OUT could not appear on real data.
3565b18fsaid the gateway records one explanation per candidate "including the ones it filtered out and why", and both the Activity drawer and the Playground's Route trace drew a block that filtered oneligible === false. Reading the router:buildRouteAttemptCandidatesonly sorts, nothing upstream drops candidates, andselectRouteAttemptswriteseligible: true, reasons: []for every candidate —eligible: falseappears nowhere outside tests. This router has no pre-attempt elimination step: it orders the policy's candidates by strategy and the fallback chain walks that order until one serves. The panel could therefore only be triggered by fabricated data, and the e2e I wrote fabricated exactly that, which is how it passed while proving nothing. The fix is not to invent an elimination stage. What the system does record per candidate is its attempt, infallback_events, so the recorded explanations now supply what they actually contain — the candidate list and its order — and the outcome is joined from the attempts: the drawer draws NOT ATTEMPTED for candidates the chain never reached, said as "no attempt was recorded for it" (a fact about the order, not a verdict about the candidate), and the Playground draws CANDIDATES with what became of each (served / failed / skipped with its reason / no attempt).readConsoleActivityRouteCandidatesstopped returningeligibleandreasonsfrom the record: both are vestigial there, and reading a field that is always true is what invited the mistake. And the Playground asserted something it could not know:(filteredCandidates?.length ?? 0) === 0rendered "none — every candidate was eligible", which is what a request with nothing recorded looked like too — the inverse of this PR's own "say only what is known", on the diagnostic page. The route now answersnullrather than[]when a request recorded no candidates, so the two are distinguishable, and the row reads "not recorded for this request" or "N recorded — listed below". The Activity drawer was already honest by omission, which is what made the inconsistency visible. Both test seeds were rewritten to the shape the gateway really writes — every candidateeligible: true, an attempt row for the one that served and none for the one that did not — so they exercise a state the system can reach. Gates:pnpm run verifyEXIT=0 (unit 549/549, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 7.5s, e2e 322.1s), zero regression. -
2026-07-27 (fifth pass): Console UI redesign — one merge-blocker, the sibling of the grants defect and in the same commit. A ceiling the operator cleared came back from the defaults after a refused creation.
9f05a927diagnosed that an empty value cannot cross a query string and built thenonesentinel for grants, then wrote the limit draft in the same file withif (value): an emptied field has nothing to write, so its parameter was absent, and the reader'sdraft(field) ?? limitFieldValue(saved, fresh)answered absent with the suggested default. Clearing all five ceilings and mistyping the name brought back budget 25, rpm 120, tpm 50000, tokens 16384 and concurrency 4 — and the next save stored five limits the operator had deliberately removed, with nothing on screen to say so. The fix is a presence marker (draft=1) rather than a second sentinel: while the marker is there the draft is the whole answer, so a missing field means cleared. A sentinel would have corrupted the other half of the same feature — these fields carry raw typed text, so an operator who typed the word "none" into a ceiling and was refused would come back to a blank field instead of their typo — and the marker also survivesbuildHref, which drops empty-valued parameters when it preserves a query string, so paging the grants browser inside the reopened dialog cannot quietly restore the defaults either. The e2e now clears all five, submits a whitespace name (past the field's ownrequiredcheck, refused by the route) and holds all five empty on the way back; reverting only the read side turns it red withBudget USD: expected "" received "25". The assertion that missed this checked only fields that had values. Gates:pnpm run verifyEXIT=0 (unit 549/549, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 6.8s, e2e 295.4s), zero regression. -
2026-07-27 (fourth pass): Console UI redesign — the test-coverage round on PR #56, from a sixth review batch listing ten behaviours said to have no guard. Four already had one, added earlier the same day beside the fixes they cover: the grants revoke-to-empty round trip (
console-grant-params.unitplus the editor e2e), the limit-clearing database round trip (api-key-limit-clearing.e2e, which reads the rules back once the save has answered), the refused-create URL backfill, and themodelPageSizebound (122 seeded models, 100 rendered). Six were real. Edit-path enforcement: the create path'swarn_onlywas pinned and the edit path's was not, so a save that rewrites every rule could have reverted the policy unnoticed;api-key-editor.e2enow switches an existing key toblock, reads every rule row back and reopens the dialog to see it. The one-time provider key page had no test at all because it was unreachable from one — a route module cannot export anything but its handlers, sorenderOneTimeProviderKeyPagemoved intoprovider-keys/_created-page.tsbeside the api-keys one, and its contract now holds that the pasted secret appears once, that everydata-copytarget exists, that the theme rule and pre-paint bootstrap are present, and that hostile input is escaped.loading.tsxpresence:console-spinner.unitwalks the dashboard segments and requires a loading state per page, which found that Playground had none — added, so a click on the tab no longer reads as a click that did nothing while the virtual models are read. The Overview's health roll-up:console-recorded-detail.e2eseeds a serving, a failing and a switched-off connection and holds2 serving · 1 failing · 1 disabled, the split this branch introduced and left unguarded. The sign-in status line:consoleStatusLinemoved to its own module (a.tsxfile cannot be imported by the unit runner) and is asserted to name no database — it renders before anything has queried, so any word about one would print the same whether the database were up or down. The Copy button is the one that repaid the exercise and then defeated it: writing a test that clicks one surfaced a real defect —copyTextawaitednavigator.clipboard.writeText, and that promise never settles in Chromium when the page is not considered focused, so the button sat on its own label with no toast and no error, which is the one state the component exists to prevent. It now races the write against a one-second timeout and falls through to the selection path, with a unit case that stubs a clipboard which never answers. What could not be done is assert it through a browser click: a synthetic Playwright click does not reach the handler in this harness, whileelement.click()frompage.evaluatedoes, hit-testing confirms the button is the topmost element at the click point, and React is attached (the fiber keys are on the node). The e2e asserts the button is offered and the click behaviour is held by the unit case; the harness question is recorded here rather than answered by a forced click or an assertion that would pass for the wrong reason — it would affect any future test of a client handler. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13 + scripts, unit 549/549 across 33 files, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 7.9s, e2e 332.4s), zero regression. -
2026-07-27 (third pass): Console UI redesign — the low-risk and engineering-hygiene round on PR #56, from a fifth review batch. URL and form handling.
providerOAuthExpiresAtwas the one OAuth parametercloseHrefdid not clear, so a closed dialog left it behind.buildHrefpreservedformError, which meant a refusal followed the operator through every page and search inside the dialog it belonged to; it is dropped like a toast now, for the same reason.MutationFormmarked whichever field its call site named and threw away thedetails.fieldthe refusal carried, so a bad PRIORITY put the invalid ring on the base url; it marks the field the server named as well.readNumberanswered undefined both for a field that was absent and for one holding "high", and every caller had a?? 100behind it — present-but-unparseable is now a 400 that names the field, which is what makes the ring land on the right one.SyncedSearchInputread its sibling draft with a document-widequerySelectorwhile the select beside it used form scope; both are form-scoped now. Displays that were saying the wrong thing. The expiry countdown seedednowwith the target, so its first frame rendered a red "expired" before correcting itself; it holds null until the first tick, which is also what keeps the server and client renders agreeing. The Overview's "→ Activity" carried no window while Usage's equivalent mapped one, and its connection roll-up folded "deliberately disabled" into "not serving yet" beside "still checking" — two different answers to whether something is serving.consoleStatusLineprinted "postgres" unconditionally on the sign-in screens, where nothing has queried yet, so it read the same whether the database was up or down; it says the version and the encryption state, which are the facts it has. A comment in the dashboard layout claimed nothing is read from the database for a visitor who has not signed in, one line under the session lookup that reads two tables. State.updateApiKeyWithSettingsskipped the rule write whenever the switch was off, so unchecking ENABLE LIMITS in the same save as editing a ceiling discarded the edit; the rules the form carried are always written andlimits_enableddecides enforcement alone (the drawer's Disable, which keeps rules without touching them, is a different call and unchanged).defaultApiKeyLimitFormValueshad no caller left. Hygiene.api/_standalone-theme.tsis a hand-written copy of the console's tokens with no guard: a new parity test holds that everyvar(--x)the two shell-less pages use is defined there, that every token it defines matches globals.css by value (the two font tokens exempted with the reason — the console's resolve to next/font variables stamped by the layout these pages render outside of), and that no hex or raw shadow colour is written by hand; the one barergba(0,0,0,.25)became--shadow-dialog. The preview harness acquired a fixture, migrations, a detached dev server and a browser before its try block, so achromium.launch()failure left the dev server and the throwaway database running; all four are released in reverse by one list, with SIGINT/SIGTERM handlers.scripts/**was covered by neither lint nor typecheck: it is in biome now (withnoConsoleoff, as tests are) andtsconfig.scripts.jsonruns inverify, which immediately found two real errors — an unmatched capture group used as an index inenv-loader.ts, andConsoleProcess.childdeclaredChildProcessWithoutNullStreamswhen the process is spawned with stdin ignored. Two halves left undone, deliberately: a never-probed connection still counts as serving, becauseprovider_health_summarystores only non-healthy rows and nothing records "probed at least once" — telling them apart needs the latest healthy event per connection, which is a query to add on purpose; and the Limits drawer and the key editor can still lose each other's writes across tabs, which is what two editors over one dataset without optimistic concurrency means. Tests: theformErrorrule and the numeric refusal as units, an e2e that types "high" into PRIORITY and checks the message and which field carriesaria-invalid, a db-level case for keeping the ceilings while switching enforcement off, and the standalone-theme parity test. One stale assertion updated:platform-foundationpins the exactverifychain, which now carries the scripts typecheck. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13 + scripts, unit 543/543 across 33 files, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 7.8s, e2e 328.8s), zero regression. -
2026-07-27 (second pass): Console UI redesign — the design-conformance round on PR #56, from two more external review batches, each claim checked against
docs/ConsoleUIRedesign(the README's data map, the runnable prototype, and the handoff screenshots) before anything was changed. Recorded data that no screen showed. The gateway writes one explanation per route candidate intorequest_activity.route_reason— including the ones it filtered out and why — and the console read only.message;readConsoleActivityRouteCandidatesnow parses them,getConsoleActivityDetailresolves the provider-model ids to names in one query (a uuid on screen is no better than nothing), and both the Activity drawer and the Playground's Route trace draw a FILTERED OUT block, which is what §4.8 asks the trace for. The Playground's trailing note stopped pointing at Activity for filtered candidates and now points there for per-attempt outcomes, which is what Activity actually adds.provider_api_keys.last_used_atreachedProviderConnectionand was dropped: the connections table has its LAST USED column back (§4.2 lists the field), with—rather than "never" for an authorized connection, whose table has no such column. §4.4 asks for the API key's cost to be annotated withrequest_costs.cost_source; the four dimension breakdowns now carryestimatedCostRequestsand the detail reads$3.75 (2 estimated). The design's own vocabulary, on the screens that use it: the Data quality panel calls theSegmentBarthat was already in the file (legend in percentages, as the prototype has it), binds colours to the source name rather than to row position (estimated overtaking provider swapped them), and lists all four cost sources so areconciledrow that produced nothing is still a row; the four distribution tables order by share instead of by first appearance, which is also what makes the Overview's "busiest 8" true;formatCapabilitiesreads the modalities that were on the row and unread (tools · vision · reasoning, nostream— every routable model streams); the Route candidates table gained CTX;formatModelContextTokenswent compact (200k,1M) while keeping the invariant its tests exist for — two decimals leave1,048,576as1.05M, distinct from1M, which is the pair a capability contract refuses; Activity's TIME says which second a request started in; the drawer badge reads200 · served by fallback; the Limits table holds the three states the design gives it, with "no rules" and "rules kept" moved to the budget cell; the API keys list says· N models; and FAILURE RATE says104 failed · 38 fallbackfrom one added indexed query rather than by pulling all ofgetConsoleUsageBreakoutsonto the Overview. Interaction rules: a candidate that would break the capability contract is now unselectable in the picker, naming the field, instead of being taken and refused by the save; Copy fires the 4s toast §3.4 requires and says when it could not copy; the gateway address in the masthead has one; Activity's filter row keeps a Clear; the two safer alternatives with a real target (Disable instead, Disable limits instead) are links; and with no stored theme the console follows the system live throughmatchMediarather than until the next reload. Left alone, with reasons: a Sign out confirm (the prototype has no Sign out at all, and §2 only lists it in the nav), a "Group buckets" selector (the prototype has a note,24 hourly buckets, which the page already renders), a third "follow system" theme state (§2 leaves the theme control to the product), and the two architectural deltas — Edit API key as a limits summary that jumps to Limits (§5), and the one-time secret as the New API Key dialog's second step rather than its own page (the plaintext exists only in that response; making it a dialog step would put it through client state). Tests:console-route-trace.unit(the recorded shape, a route recorded before the field existed, half-recorded entries),console-recorded-detail.e2e(one routed request: the filtered candidate with its reason,(2 estimated)against three requests where two were priced by the gateway,20 min ago/neverfor two connections) plus a second case in it for the design vocabulary (200k, tools · vision, CTX,· 1 model, rules kept,14:32:05, served by fallback, Clear,· 1 fallback); the Playground trace is covered inplayground-streaming.e2eby seeding the activity row the stub gateway's request id points at; andconsole-shared-formatters.unitgained the seconds clock and the capability vocabulary. Three stale assertions repaired: a source-string check pinning the context formatter's implementation beside the behavioural check of the same thing; the route dialog case that picked a mismatching candidate and asserted the save refused it (now: the pick is refused where it is offered, and the save-time message stays pinned invirtual-model-capability-contract.unit); and the parity case reading the key prefix with a locator the new list subtitle also matches. One defect of my own, caught by the regression: the Playground readfilteredCandidates.lengthunconditionally, so a recorded detail without the field — an older request, or a test stub — threw and took the response pane with it; the field is optional and read defensively. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 539/539 across 33 files, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 6.8s, e2e 287.2s), zero regression. -
2026-07-27: Console UI redesign — second review round on the same branch (PR #56), driven by two external review batches read back against the worktree. Two data-correctness defects. (1) Revoking the last grant restored every grant. The grants being edited travel in the query string, and an empty value cannot carry the empty set:
buildHrefwrote?grantIds=,readParamreads""as absent, and the dialog answers absent by falling back to the grants the key already has — so unchecking the third of three re-checked all three and the save wrote them back unchanged, where the correct answer was a refusal. The same mechanism dropped a cleared default:defaultGrant=""fell back to the default just revoked, and the hidden field then posted a default outside the allowed set, whichassertDefaultVirtualModelIsAllowedrefuses for a state the screen no longer shows. New_ui/api-keys/grant-paramsnames the empty set (none, which no UUID can collide with) and owns the write and both reads; the create-refusal redirect writes both parameters unconditionally for the same reason; the Save button, already disabled with no grants, now says why. (2) A cleared limit field kept its old ceiling.replaceApiKeyLimitRulesWithClientdeleted only thelimit_types it was about to write, and a blank field produces no rule at all — so "Leave a field empty for unlimited" left the rule in place and the gateway went on enforcing it, and clearing every field deleted nothing. Every caller submits the whole rule set, so the delete is now per key. Provider connection settings bypassed their own rules:updateProviderApiKeySettingsandupdateProviderOAuthConnectionSettingswrotelabel/prioritystraight into SQL while the paste path normalized them, so editing a connection without pasting a key stored priority 9999 or a 101-character label (provider_api_keysonly constrainspriority >= 0); both now normalize, and the OAuth normalizers raise a validation error instead of a bareError, so a typo renders in the dialog rather than as a 500.?modelPageSize=was clamped on the display path only — the page passed the raw value to the query, so a hand-written 100000 materialised the whole model table; onereadPageSizeParamnow bounds both. A budget window of the wrong period still paired with the rule: the query preferred a matchingperiod_typebut fell back to any current window, so a key switched to a daily budget read a month of spend against a daily ceiling; a window is now reported only when its period matches the enabled rule (a key with no budget rule still reports its window). Console truth-telling. The Overview's failures list carried "from the last …" while its query applied no window at all (andformatRelativedegraded to a bare date past 24h); it now filters by the same window as every other number on the page and says so. The Playground's toast promised "It counted toward the key's limits and appears in Activity" under a 401 that was never attributed to a key. A refused/api/playground/resultlookup threw away an answer already in hand, and a stream cut mid-flight left the pane saying "streaming…" for the rest of the session.CopyButtoncallednavigator.clipboard?.writeText, which is absent on plain http — the optional call did nothing, said "copied", and the fallback the standalone pages carry was never shared; new_ui/copy-textholds the clipboard-then-selection path and the button reports "copy failed". The integration snippets went back to<YOUR_API_KEY>: the stored prefix made a syntactically complete line holding a truncated secret beside its own Copy button, and which key it is belongs in the note. A link naming a connection this provider does not have asserted "it was deleted since this page was opened" (also for a URL with no connection at all, and for another provider's connection, which is still there) and the edit dialog silently became "Add API key"; both now reach one honest dialog. A refused creation no longer replaces the console with a JSON body on an unexpected failure — it returns to the dialog with the message and an error id matching the log line — and carries the whole limit draft back, so one wrong field no longer costs six retyped ones.saveProviderApiKey's JSON answer stopped echoing the pasted plaintext to page scripts that never read it. Tests:provider-connection-settings.e2eand two db-level cases inapi-key-management.e2e(one field cleared, then every field; a budget window only for its own period);api-key-limit-clearing.e2edrives the Limits drawer and reads the rules back once the save has answered;console-grant-params.unitandconsole-copy-text.unit; new sections inapi-key-editor.e2e(revoke the default, revoke the last, clear a limit field, a refused creation keeping its draft),console-providers-ia-and-forms.e2e(the secret never in the answer, a stale connection link),overview-list-caps.e2e(a three-day-old failure absent at 24h, present at 7d),provider-list-collapse.e2e(122 models,modelPageSize=100000renders 100),playground-streaming.e2e(a 401's toast, a cut stream) andconsole-api-hygiene.unit(the error id). Two stale assertions repaired:console-interactionswaited fordefaultGrant === "", the encoding the grants fix replaced, andapi-key-dialog-paritycompared the detail's snippets against the created ones with the prefix substituted — it now expects the placeholder and additionally holds that the prefix appears in no snippet. Two review items were left as design decisions rather than defects: connection enable/disable living in the edit dialog's STATE field (what the design screen does, leaving two route actions without a UI caller) and the candidate browser's single-provider select. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 534/534 across 33 files, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified in one batch (unit 7.9s, e2e 283.3s), zero regression. -
2026-07-26: Console UI redesign — review round on the same branch (PR #56), 20 commits on top of the shipped feature, each fix landing with the test it was missing. Route policy refusals name every disagreement:
resolveVirtualModelCapabilityContractreturned on the first mismatching capability, so a pair differing on modalities and context window reported only the modalities; it now collects a mismatch per field and names both candidates (candidates carry an optionallabel— Console passes the option label, GatewayproviderKey - modelId), withdetailskeeping the first mismatch's keys and gaining amismatchesarray. API key editor rebuilt to the design: full-width NAME; a grants browser with its own search,Show: all / granted only / not grantedand a pager in the section header (8 per page, URL state, so a grant made on page one survives paging); ENABLE LIMITS as a checkbox (unchecked posts nothing, which the route already read as off); ENFORCEMENT as the sameblock / warn_onlyselect the Limits drawer has, replacing a disabled box with a hidden field; periods said as monthly/weekly/daily from one shared list used by the dialog and the drawer.EditorNavmoved to_ui/now that two editors carry a typed draft through URL navigations, and the self-applying search box stops Enter submitting the form it sits in. Agent setup moved into its own dialog behind a "Set up an agent" button beside Edit: eight platforms of instructions no longer sit permanently under the key's own state; the key, gateway and default model go in the dialog's title line; each guide's endpoint sentence became a note after the steps (a precondition about the route, not a step); and the snippets carry the stored prefix with an amber note saying so, instead of a<YOUR_API_KEY>placeholder that reads like a value. The two shell-less pages (api/api-keys/_created-page,api/provider-keys) now resolve the theme the way the console does — stored choice wins, system decides only without one — through a sharedapi/_standalone-themecarrying the tokens, the pre-paint bootstrap and one copy script; before, creating a key in a light console on a dark desktop landed on a dark page, and the provider key page was still on the pre-redesign palette. Copy buttons sit in the corner of the box they copy (outside the<pre>, so "Copy" is not copied with the snippet) with an execCommand fallback for plain-http hosts, and the secret field became a textarea so the button cannot push its tail out of sight. Test in Playground carries the just-created secret into the Playground's key field through sessionStorage — never the URL, which is kept in history and sent as a referrer — and the Playground consumes the entry on mount. Jumps between modules carry their subject through one helper (_ui/cross-links): → Activity filtered to the key, to a model's failures, or to failed requests with Usage's window mapped onto Activity's shorter horizon; → Limits narrowed to the key with its rules open; the masthead's failure count opening those failures (its own link beside the tab, since the tab is still the way to the module); Getting started's Limits step opening the keys with no rules. Two rendering bugs: the usage axis drew five labels across however many buckets the trend had, and the trend only carries buckets that saw traffic — three hours of requests produced two children keyed06:00and React refused the second (labels now match bucket count, and the axis keys by position); and the masthead's module row went accent-blue because the badge restructure left the anchors without a colour of their own, falling through to the stylesheet's link colour. Tests added this round:api-key-editor.e2e(grants paging, search, Show filter, the typed name surviving every filter click, enforcement + period persisted, ENABLE LIMITS off creating a key with no rules, Cancel dropping the whole draft from the URL);console-cross-links.unit(each href, the parameter names checked against what the two pages read, and a guard against hand-written/activityand/limits);console-usage-axis.unit(1–4 buckets, 24 buckets, days, empty); the presentation contract now renders the one-time page and checks everydata-copytarget exists, the theme rule and bootstrap are present, and the hand-off isdata-handoffrather than a URL;console-visual-design.e2epins the module row's ink/dim colours against the accent;console-providers-ia-and-forms.e2ecovers a local provider's endpoint offering no Delete and no second endpoint. Three stale assertions repaired, all describing screens that had changed under them: the provider OAuth dialogs'Open authorization URL/Your codebecameOpen in browser/Open · copy code; the quota probe switch became a field of the connection dialog (the case now saves it and waits for the response before reading the row back); and the create route's refusal-to-the-dialog path was answering every caller with a redirect, so an API client got 200 HTML where it asked for 400 JSON — it is now taken only for a request that asked fortext/html. Overview relaid out to its design screen: the requests chart takes the full width, and the panels below it read as two bands — Connection health / Plan quota / Recent failures, then Usage by provider / Usage by API key — instead of a narrow right rail beside the chart. Every list on the page is capped, because the page is read at a glance and its height is part of that: connection health draws 4 rows worst-first (failing, then checking, then serving) and rolls the rest into one row; Plan quota draws the 2 plans closest to their ceiling and counts the ones with more room below the list; the three panels in that band share one body height so they end together, rather than the tallest one setting where the usage tables start; the two usage tables draw the busiest 8 and say how many are left in Usage; recent failures stays at the 5 the query asks for. Each capped panel carries a test id andoverview-list-caps.e2eseeds 9 providers and 11 keys to hold the counts, the ranking, and the sentence each list uses to say what it is not showing. The Playground streams again: withstream=trueit read the body withresponse.text()and parsed the frames after the request finished, so the answer appeared all at once at the end — the request was streamed, the page was not. The body is now read with a reader and an SSE decoder that holds back a frame the network cut in half, and the answer renders as it is written, with the response panel taking over when it ends. A refused Playground request says why: the gateway answers{error:{code,message}}, and the console read only the success shapes, so picking an endpoint the virtual model is not routed to showed400 erroroverNo response text. The refusal is now read as a refusal —route_not_found · No route policy is available for the selected Virtual Model.— and, since the console already knows which endpoint each model is routed to, the protocol picker says so before the request is sent. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 524/524 across 33 files, build);pnpm run verify:featuresEXIT=0 — all 33 passing features re-verified, zero regression. -
2026-07-26: Console UI redesign shipped (33rd passing feature,
console-ui-redesign) — the Console replaced wholesale against the finished design, with the old UI referenced only for how it calls the backend. Tailwind v4 introduced (@tailwindcss/postcss,@import "tailwindcss",@custom-variant dark,@theme inline,@utility):globals.cssgoes 4178 → 215 lines and holds the token table as CSS variables —:rootlight,[data-theme="dark"], plus a duplicatedprefers-color-scheme: darkblock so a dark-preference visitor never sees a flash of the light canvas before the bootstrap script runs; the type ladder (11 → 31px), radii 3/4px, the three shadows, and two utilities (tabnum,cell-clip). No hex values in components; status colour is green/amber/red only. 41 legacy console files deleted, 44 new under_ui/(apps/console: +8389 / −12984 across 102 files);_components,_modules,_libare gone. All eight pages are server components whose selection, filters, paging and dialog state live in the URL, so a dialog is rendered from the selected object rather than client state. Four helpers were restored from the deleted tree because their tests recorded behaviour the rebuild had regressed:model-capability-format(exact grouped context tokens —1,048,576, not a1Mrounding that makes two distinct windows look identical),provider-relative-time,provider-health(one healthy connection ⇒ the provider is healthy),provider-quota-format(the non-numeric quota states). Backend:packages/dbgainsconsole-runtime-status(footer: server version + active worker jobs) and the three queries the design listed as missing — trend-bucket failure counts,getConsolePreviousWindowKpisfor the Overview period-over-period deltas,listConsoleVirtualModelCandidateTrafficfor per-candidate share — plusgetConsoleUsageBreakouts(sequential awaits: one pooled client serialises and pg warns on concurrent queries);console-api-keys+lastUsedAt/setApiKeyLimitsEnabled,console-api-key-limits+listConsoleCurrentBudgetPeriodsandenforcementPolicy,console-providers+listConsoleProviderModelRefreshStatuses,console-route-policies+availability/pageSizeandlistProviderModelOptionsByIds,console-virtual-models+listConsoleApiKeyVirtualModelGrants.console-formatshrank to the shared USD rule alone — the count and timestamp helpers had no caller left once the console owned its own display vocabulary. E2E rewritten in the same PR (22 case files): the suites drive the dialogs the redesign has rather than the removed row controls, and pin the interaction rules — a refused mutation renders in place, only idempotent actions (Re-check, Refresh models, send) report through the 4s toast, one page of results renders no pager, a header count and its pagination range share one denominator. Defects found and fixed, each the rebuild dropping something the API or the old console already knew: overlays weredivs withrole=dialog(no focus containment, no Escape, no focus restore — now native<dialog>+showModal); the route editor's endpoint select decided nothing while open, so an incompatible candidate could be picked and only the save refused it; navigating inside that editor wiped the typed name and description; destructive confirms stayed open over the object they had just deleted; deleting a provider a route still points at was offered as a button the API answers 409; provider health was colour-only with the provider-level rollup stated nowhere; the Playground swapped the console's model list for the gateway's answer with no loading state and read the wrong request-id header; three nested-<form>bugs (the browser drops the inner form, so the control submitted its parent). A dev-only preview harness (screenshots all 8 pages + dialogs in both themes, audits DOM nesting, probes 390px overflow) caught the nesting and overflow classes of bug. Gates:pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 500/500 across 33 files, build);pnpm run verify:featuresEXIT=0 — All 32 passing feature(s) re-verified, zero regression; full Playwright suite 130/130; this feature's own verification EXIT=0 (unit 71/71, e2e 35/35). -
2026-07-24: Batch 8 Feature B shipped (32nd passing feature,
batch8-grok-responses-quota) — the local Batch 8 Grok plan, rebased ontodevafter fireworks quota + base-url-presets tracker. TDD red-to-green. Grok gains its second routable face and its quota probe. Registry:grok.endpoints+responses(the upstream'sgrok-*-multi-agent*variants are Responses-only, same proxy base),quotaSourceflippednot_supported→{ supported: true }. Responses seam (gateway-responses.ts): filter extended codex-only→codex-or-grok via the extracted predicateresponsesSupportsSubscriptionProvider(+guard that claude_code/minimax_coding stay rejected), callProvider +grok dispatch;createGrokSubscriptionAdaptergained aresponsemethod (POST base+/responses, grok headers); the shared grok streaming dialect already serves the responses suffix (now registry-supported). Quota (quota-probe.ts):quotaProbes.grok= a bespoke double-request probe (grokQuotaProbeConfigcentralizes billing path, credits query, three headers Bearer +X-XAI-Token-Auth+ Accept, 15s timeout) —GET /billing?format=creditsthenGET /billing; exportedparseGrokCreditsQuota(periodcreditUsagePercent0-100→0-1 clamp, missing field→0 for a new period; window fromcurrentPeriod.typeelse weekly) andparseGrokMonthlyQuota(used.val/monthlyLimit.valUSD cents; a zero-value Cent serializes as{}→0;monthlyLimit≤0→no window). Error map: 401→unauthorized; any other non-2xx incl. 402 exhaustion→probe_failed(402 never a probe quota signal); failed credits degrades to the monthly window alone. Pins (post-rebase with fireworks already supported):expectedRegistrygrok +responses + supported, provider-quota supported[] 12→13 (+grok) / unsupported holds at 19 (grok was Feature A's not_supported bump then removed here),quotaProbes.grokregistered; registry (35) / remoteKeys (32) / selector / price / long-tail / coverage unchanged. Tests:batch8-grok.unit.case.tsdescribe "Feature B" (5 it) + Feature A ①/⑤/⑥ updated to the dual-face shape; egress case +responses scenario (POST/v1/responses); Console E2E grok chip →['Chat Completions','Responses']. Docs:docs/PRODUCT.mdSubscription list +Grok + Batch 8 paragraph/bullet (dual faces, popup OAuth at auth.x.ai,/billingdual-window quota, 403 gate closed-loop → switch to thexaiAPI key template, 402 = credits exhausted). Live-key risk (R1): OAuth flow, 426 egress gate, 403 gate, and probe field semantics are backed by upstream client source, not a real SuperGrok account; grok client version + probe params are single-point constants for post-launch correction.pnpm run verifyEXIT=0 (unit 520/520, build 13/13); feature verification EXIT=0 (unit 125/125 across the 4 files; e2e batch8-grok-egress 1/1 + console Grok test 1/1);pnpm run verify:featuresEXIT=0 — All 32 passing feature(s) re-verified (unit batch 7.5s, e2e batch 314.4s), zero regression. Post-rebase onto origin/dev (fireworks quota + base-url-presets tracker). -
2026-07-24: Batch 8 Feature A shipped (31st passing feature,
batch8-grok-oauth-chat) — the local Batch 8 Grok plan. TDD red-to-green. Grok joins as the 4th subscription provider and the first to route the OpenAIchat_completionsface. Registry (packages/config/src/provider-registry.ts):grok— subscription, basehttps://cli-chat-proxy.grok.com/v1(official inference proxy, notapi.x.ai),chat_completionsface,modelListStyle/connectivityProbeStylegrok,subscriptionAdaptergrok,metadataKeyxai, popup authorization-code OAuth againstauth.x.ai(authorize/token/revoke, clientIdb1a00492…,clientIdEnvVar GROK_OAUTH_CLIENT_ID, redirect127.0.0.1:56121/callback, form encoding). Types +grok ×3 (subscriptionAdapter/modelListStyle/connectivityProbeStyle). Headers (subscription.ts):buildGrokSubscriptionHeaders+ singlegrokClientVersionconstant feeding both thegrok-shellUser-Agent andx-grok-client-version(the proxy's HTTP 426 gate); six client-identity headers, content-type added by POST callers. Chat seam (D3):gateway-chat-completions.tsfilter → adapter allowlist via extractedchatCompletionsSupportsSubscriptionProvider(+guard: claude_code/openai_codex/minimax_coding stay rejected), callProvider dispatches grok tocreateGrokSubscriptionAdapter; grok streaming dialect overrides onlybuildHeaders.model-list.ts/connectivity.tsgrok branches reuse the header builder;console-provider-templates.tsSubscriptionProviderTemplateId+grok. Pins: registry 34→35,subscriptionProviderKeys3→4,listProviderTemplateEntries32→33, remoteKeys 31→32, subscription group 3→4; price/long-tail/coverage unchanged (quota-supported held for Feature B; fireworks already on the supported list fromdev). Tests:batch8-grok.unit.case.tsdescribe "Feature A" (6 it) + shell;batch8-grok-egress.e2e.case.ts+ shell; Console E2E grok in the Subscription group + authorization-code dialog linking toauth.x.ai..env.example/ARCHITECTURE.md untouched. Post-rebase gates covered by the Feature B wrap-up verification below (pnpm run verifyEXIT=0;pnpm run verify:features32/32). -
2026-07-24: Fireworks quota probe shipped (30th feature,
fireworks-quota-probe) — two-hop control-plane probe:GET {origin}/v1/accountsresolves the API key's account slug, thenGET {origin}/v1/accounts/{slug}/quotas?pageSize=200yields themonthly-spend-usdrow rendered as amonthly_budgetWindowEntry (utilization = usage/value). Prepaid credit balance has no public endpoint and is not shown. Live-key risks retained as mock-tested only: (1) inference keys may 403 on control-plane routes (degrades tounauthorized); (2)usageassumed month-to-date dollars; (3) multi-account keys take the first listed account. Batch 4's feature description retains its historical "no probe" wording — this entry supersedes it for fireworks. RegistryquotaSourceflip to{ supported: true }; supported probes 11→12, unsupported 20→19. Implementation:packages/provider/src/quota-probe.ts(fetchProbeJsonsplit +fireworksQuotaProbe/parseFireworksQuota),packages/config/src/provider-registry.ts, unit/e2e pins. Docs:docs/PRODUCT.mdBatch 4 intro + Fireworks bullet.pnpm run verifyEXIT=0 (unit 508/508, build 13/13); feature verification EXIT=0 (unit 51/51, e2e 14/14).pnpm run verify:featuresEXIT=0 — All 30 passing feature(s) re-verified (unit batch PASS 8.3s, e2e batch PASS 265.3s), zero regression. -
2026-07-24: Registered pending follow-up
provider-template-base-url-presetsin feature_list.json — a registry-driven base-URL preset picker for the Add Provider dialog, covering the one-key-many-bases providers (Bedrock mantle/runtime endpoints, GLM domestic/global, Xiaomi Token Plan cn/sgp/ams) instead of a per-provider control. Tracker entry only; no implementation. -
2026-07-24: Batch 7 shipped (29th feature,
batch7-bedrock-provider) — the local Batch 7 Bedrock plan, a single feature. TDD red-to-green. AWS Bedrock joins as a paste-key (ABSK Bearer) OpenAI chat_completionsapi_keyprovider on the documented mantle OpenAI-compatible face: default basehttps://bedrock-mantle.us-east-1.api.aws/v1,priceSyncSupported: true,quotaSource requires_separate_credential, single Chat Completions chip, base editable (14 mantle regions + bedrock-runtime variant). Wiring (packages/config/src/provider-registry.ts):KnownProviderKey33→34 (bedrockafter anthropic),providerRegistry/knownProviderKeys+1,providerTemplateSelectorOrder+1 (bedrockafter deepseek);console-provider-templates.tsOpenAICompatibleProviderTemplateId+1 and ids array +1 appended;price-source.tsaliasamazon-bedrock→bedrock. Terminal pins: registry 33→34, template entries 31→32, remoteKeys 30→31, unsupported 19→20, remote_api_key 25→26, long-tail 22→23, price-sync 20→21, quota-supported (11) / subscription (3) unchanged. New tests:batch7-bedrock.unit.case.ts(5 it) + shell,batch7-bedrock-egress.e2e.case.ts(1 chat) + shell, Console E2E Batch 7 bedrock template. Docs:docs/PRODUCT.mdAPI Key list +AWS Bedrock + Batch 7 bullet.pnpm run verifyEXIT=0 (unit 503/503, build 13/13); feature verification EXIT=0 (unit 113/113, e2e 15/15).pnpm run verify:featuresEXIT=0 — All 29 passing feature(s) re-verified (unit batch PASS 6.7s, e2e batch PASS 290.3s), zero regression. -
2026-07-24: Batch 5 shipped (28th feature,
batch5-token-plan-providers) — the local Batch 5 provider plan, a single feature completing the token-paste alignment track. TDD red-to-green. Three subscription-plan paste-key (api_key) providers in the remote_api_key selector group:opencode_go(OpenCode Go, basehttps://opencode.ai/zen/go/v1) has two routable faces from one base (command_code precedent) — OpenAI Chat Completions with aBearerkey, plus Anthropic Messages where the upstream authenticates with a barex-api-key(hardcoded by the anthropic adapter;creation.authstays Bearer for the chat/Console face), with default Bearer connectivity/model discovery;xiaomi_token_plan(Xiaomi MiMo Token Plan, default sgp basehttps://token-plan-sgp.xiaomimimo.com/v1, base user-editable with cn/ams documented) andmistral_vibe(Mistral Vibe, basehttps://api.mistral.ai/v1shared with the standardmistralas a distinct key) are chat_completions-only with Bearer. Quota: opencode_go + xiaomi_token_plannot_supported, mistral_viberequires_separate_credential(same judgment as mistral); no probe, nopriceSyncSupported, nometadataKey. Wiring (packages/config/src/provider-registry.ts):KnownProviderKey30→33 (mistral_vibe after mistral, opencode_go after openai_codex, xiaomi_token_plan after xiaomi),providerRegistry/knownProviderKeys+3,providerTemplateSelectorOrder28→31 (opencode_go, xiaomi_token_plan, mistral_vibe inserted after ollama_cloud);console-provider-templates.tsOpenAICompatibleProviderTemplateId+3 (alpha) andopenAICompatibleProviderTemplateIds+3 appended in selector order. Egress/adapter reuse, no new code (buildChatCompletionsUrl for the three chat faces; buildAnthropicMessagesUrl for opencode_go). Terminal pins (same commit as impl): registry 30→33 +expectedRegistry+3,listProviderTemplateEntries28→31, remoteKeys 27→30, unsupported 16→19 (opencode_go + xiaomi_token_plan not_supported, mistral_vibe requires_separate_credential), remote_api_key selector group 22→25, long-tail 19→22, coverage longTailTemplates 19→22 + providerScenarios +opencode_go messages; both price-sync pins held at 20 (D6: subscription plans have no per-token list price, and opencode_go's bare model ids would collide in metadata tier-1 — priceSyncSupported deliberately NOT set), quota-supported (11) and subscription (3) unchanged. New tests:batch5-providers.unit.case.tsdescribe "batch 5 token plan providers" (6 it: opencode_go dual-endpoint entry toEqual; xiaomi_token_plan + mistral_vibe chat-only entries toEqual incl. mistral_vibe requires_separate_credential; egress URL routing via buildChatCompletionsUrl ×3 + buildAnthropicMessagesUrl→…/zen/go/v1/messages; default connectivity/model-list styles + opencode_go reverse assertion; no quota probes + API Keys group + chip data; mistral_vibe same-base-distinct-key contract) + shell;batch5-provider-egress.e2e.case.ts(3 chat scenarios, 200/choices, POST base+/chat/completions, Bearer, succeeded request_activity) + shell;provider-coverage-smoke.ts+opencode_go Anthropic messages scenario (x-api-key,/opencode_go/zen/go/v1/messages) withprovider-coverage.unit.case.tsscenario-list sync; Console E2E "Add Provider API Keys group carries the Batch 5 token-plan templates" (opencode_go chipsChat Completions+Messages, xiaomi_token_plan + mistral_vibe single chip, base prefills, no overflow 1280/390). Docs:docs/PRODUCT.mdAPI Key list +3, Batch 5 paragraph + three bullets (opencode_go dual-endpoint, xiaomi_token_plan three regions + editable base, mistral_vibe same-base-distinct-key + Unknown metadata);docs/ARCHITECTURE.md/.env.exampleuntouched;docs/PROVIDER_QUOTA.mdabsent from repo (not created).pnpm run verifyEXIT=0 (lint 368 files clean, typecheck, unit 498/498, build 13/13); feature verification EXIT=0 (unit 114/114 across 4 files, e2e 19/19 incl. batch5 egress + the new Batch 5 form test + provider-coverage);pnpm run verify:featuresEXIT=0 — All 28 passing feature(s) re-verified (unit batch PASS 7.9s, e2e batch PASS 249.9s), zero regression. -
2026-07-23: Batch 4 Feature B shipped (27th feature,
batch4-price-sync-expansion) — the local Batch 4 provider plan §10 step 2, on top of Feature A. TDD red-to-green. Price-sync allowlist 13→20:priceSyncSupported: trueadded togroq/cerebras/fireworks/mistral/nvidia/xiaomi+cline_pass(its catalog section carries the channel's own resale prices);ollama_cloudstays off (subscription-billed, no per-token cost).packages/provider/src/price-source.ts:providerKeyAliases+4 —cline-pass→cline_passandfireworks-ai→fireworksare price-path-required (models.dev section names),zai-org→zaiandminimaxai→minimaxare metadata-prefix-only (no same-name models.dev section, zero price impact). W1 prefix-vendor resolution:resolveProviderModelMetadataEntrygainsfindPrefixVendorRegistryEntrybetween the provider-scoped lookup and the tiered cross-catalog sweep — avendor/modelid resolves straight fromresolveRegistryCatalogKey(prefix)'s catalog via the existingcrossCatalogIndex, so a prefixed id listed by several now-tier-1 host catalogs (openrouter/nvidia/groq) no longer degrades into an unresolvable trusted-layer conflict; the bare-id hard stop is unchanged and provenance (resolvedVia/resolvedFromCatalog) is stamped by the existing worker-side comparison. No-W1-red proof captured: after landing flags+aliases+pins but before the prefix-vendor function, the item-1 test returnednull(the tier-1 degradation, nvidia now allowlisted); adding the function turned it green. New tests:provider-model-metadata-fallback.unit.case.tsdescribe "prefix-vendor resolution and price allowlist expansion" (5 it: prefix-vendor beats trusted-layer conflict; HF-style aliases zai-org/minimaxai past a stripped-form conflict, case-insensitive; bare-id + hard stop unchanged; provenance stamped on a prefix-vendor hit viaenrichListedProviderModels; price allowlist expansion with ollama-cloud dropped). Consequence/guard updates (cline_pass moved onto the allowlist): the guard "keeps the price gate closed to non-allowlist sections" re-pointed cline_pass→zhipuai (given cost so the rejection is on merit); two base tests using cline_pass as a tier-2 example re-pointed tonovita; the Batch 3 cline_pass entry test and the Batch 4 field-for-field test updated to assert the newpriceSyncSupportedflag (six of seven; ollama_cloud unset). Pins (B): both price-sync arrays 13→20 (provider-registry.unit.case.ts+provider-descriptor.unit.case.ts),expectedRegistry+7 flag rows; registry key count (30)/remoteKeys (27)/quota unsupported (16)/selector (22)/long-tail (19) unchanged from A. Docs:docs/PRODUCT.mdAPI Key list +7, seven provider bullets (six note auto price sync, ollama_cloud notes subscription billing), cline_pass bullet gains a price-sync sentence;docs/PROVIDER_QUOTA.mdabsent from repo (not created);.env.example/docs/ARCHITECTURE.mduntouched.pnpm run verifyEXIT=0 (491 unit + build); Feature B verification EXIT=0 (54 unit + 1 e2e);pnpm run verify:features27/27 zero regression. -
2026-07-23: Batch 4 Feature A shipped (26th feature,
batch4-inference-cloud-providers) — the local Batch 4 provider plan §10 step 1. TDD red-to-green. Seven pay-as-you-go inference clouds join as pure OpenAI Chat Completions paste-key (api_key) providers with Bearer egress and default connectivity/model discovery:groq(https://api.groq.com/openai/v1),cerebras(https://api.cerebras.ai/v1),fireworks(Fireworks AI,https://api.fireworks.ai/inference/v1),mistral(https://api.mistral.ai/v1),nvidia(NVIDIA NIM,https://integrate.api.nvidia.com/v1),xiaomi(Xiaomi MiMo,https://api.xiaomimimo.com/v1),ollama_cloud(Ollama Cloud,https://ollama.com/v1). Behavior carries onlyquotaSource— sixnot_supported,mistralrequires_separate_credential(its Admin usage API needs a separate enterprise credential); no probe, and (this feature) nopriceSyncSupported.ollama_cloudis a remoteapi_keyprovider kept independent from the Localollamadaemon by a dedicated assertion. Wiring (packages/config/src/provider-registry.ts):KnownProviderKey23→30,providerRegistry/knownProviderKeys+7 (alphabetical),providerTemplateSelectorOrder21→28 (mistral, groq, cerebras, fireworks, nvidia, xiaomi, ollama_cloud inserted after nous);console-provider-templates.tsOpenAICompatibleProviderTemplateId+7 andopenAICompatibleProviderTemplateIds+7 (selector order). Egress reuses the OpenAI adapter, no new code. Pins (A): registry 23→30,listProviderTemplateEntries21→28, remoteKeys 20→27, unsupported 9→16 (six not_supported + mistral requires_separate_credential), remote_api_key selector group 15→22, long-tail 12→19, coverage longTailTemplates 12→19; price-sync (13), quota-supported (11), subscription (3), coverage providerScenarios unchanged. New tests:batch4-providers.unit.case.ts(5 it, table-driven over the seven) + shell,batch4-provider-egress.e2e.case.ts(7 chat scenarios) + shell, Console E2E "Add Provider API Keys group carries the Batch 4 inference cloud templates" (all seven: base prefill + single Chat Completions chip, no overflow 1280/390).pnpm run verifyEXIT=0; Feature A verification EXIT=0 (113 unit + 13 e2e);pnpm run verify:features26/26 zero regression. -
2026-07-23: Batch 3 Feature B shipped (25th feature,
batch3-clinepass-byteplus-providers) —cline_pass(ClinePass) +byteplus_coding(BytePlus ModelArk), the local Batch 3 provider plan §10 step 2. TDD red-to-green. Both are OpenAI chat_completions-only paste-key (api_key) providers with Bearer egress: cline_pass basehttps://api.cline.bot/api/v1, byteplus_coding basehttps://ark.ap-southeast.bytepluses.com/api/coding/v3; behaviorquotaSource {reason:not_supported,supported:false}only (no probe, nopriceSyncSupported, nometadataKey). byteplus_coding carries the_codingsuffix but is paste-key (distinct from the subscriptionminimax_coding); it is pinned chat-only by a reverse assertion (endpoints.messages === undefined) because its upstream Anthropic endpoint lives under a different base path segment (…/api/coding/v1/messages) that a single base cannot express (§9-R2). Wiring (packages/config/src/provider-registry.ts):KnownProviderKey/knownProviderKeys/providerRegistry+2 (alphabetical: byteplus_coding after anthropic, cline_pass after claude_code),providerTemplateSelectorOrderinserted cline_pass, byteplus_coding between command_code and nous → terminal…glm_coding, command_code, cline_pass, byteplus_coding, nous, ollama…;console-provider-templates.tsOpenAICompatibleProviderTemplateId+2 andopenAICompatibleProviderTemplateIdsappended cline_pass, byteplus_coding (long-tail/smoke order). Egress reuses the OpenAI adapter, no new code. Terminal pins (B): registry 21→23,listProviderTemplateEntries19→21, remoteKeys 18→20, unsupported 7→9 (byteplus_coding + cline_pass), remote_api_key selector group 13→15, long-tail 10→12, coverage longTailTemplates 10→12; coverage providerScenarios, both price-sync pins, quota-supported (11) and subscription (3) unchanged. New tests:batch3-providers.unit.case.tsdescribe B (5 it),batch3-provider-egress.e2e.case.ts+2 chat scenarios, Console E2E batch3 test extended with ClinePass + BytePlus ModelArk (base prefill + single Chat Completions chip, no overflow 1280/390). §11 docs:docs/PRODUCT.mdAPI Key list +4, coding-plan two-form paragraph updated (paste-key command_code/cline_pass/byteplus_coding,_coding-suffix caveat, nous plain paste-key), +4 provider detail bullets with base URLs/protocols/key-prefix hints (user_/sk_, not validated);docs/ARCHITECTURE.mduntouched.pnpm run verifyEXIT=0 (unit + build); Feature B verification EXIT=0 (118 unit + 12 e2e);pnpm run verify:features25/25 zero regression. -
2026-07-23: Batch 3 Feature A shipped (24th feature,
batch3-commandcode-nous-providers) —command_code(Command Code) +nous(NousResearch), the local Batch 3 provider plan §10 step 1. TDD red-to-green. command_code (basehttps://api.commandcode.ai/provider/v1) is the firstapi_keyprovider with two routable faces from one base: OpenAI Chat Completions with a Bearer key, plus Anthropic Messages where the upstream authenticates with a barex-api-key+anthropic-version(hardcoded by the generic anthropic adapter —creation.authstays Bearer for the Console/chat face). Model discovery/connectivity are the default Bearer Chat Completions path. nous (basehttps://inference-api.nousresearch.com/v1) is chat_completions-only with Bearer. BothquotaSource {reason:not_supported,supported:false}, no probe, nopriceSyncSupported, nometadataKey. Wiring +2:KnownProviderKey/knownProviderKeys/providerRegistry,providerTemplateSelectorOrder(command_code, nous after glm_coding),OpenAICompatibleProviderTemplateId+ id list; command_code stays in the OpenAI-compatible category despite its messages face (category only drives Console grouping + long-tail smoke; chips + egress come from registry endpoints). command_code's messages egress (x-api-key, notauthorization) is exercised end-to-end by aprovider-coverage-smoke.tsscenario (path/command_code/provider/v1/messages). Pins (A): registry 19→21, template entries 17→19, remoteKeys 16→18, unsupported 5→7, selector remote_api_key 11→13, long-tail 8→10, coverage longTailTemplates 8→10 + providerScenarios +command_code messages; price-sync/quota-supported/subscription unchanged. New tests:batch3-providers.unit.case.tsdescribe A (5 it) + shell,batch3-provider-egress.e2e.case.ts(2 chat scenarios) + shell, Console E2E "Add Provider API Keys group carries the Batch 3 paste-key templates" (command_code dual chipsChat Completions+Messages, nous single chip, base prefill, no overflow 1280/390).pnpm run verifyEXIT=0; Feature A verification EXIT=0 (113 unit + 6 e2e);pnpm run verify:features24/24 zero regression. -
2026-07-23: Cross-catalog model metadata fallback shipped (23rd feature,
provider-model-metadata-fallback) — the local metadata fallback plan, zero DDL. Three parts. (1) Full catalog: registry ingestion stops enforcing the 13-provider price-sync allowlist — the four registry-side normalizers (normalizeModelsDev/OpenRouter/LiteLlm/VercelAiGateway...RegistryEntries) switched fromnormalizeProviderKeyto a newresolveRegistryCatalogKeythat aliases the 13-family keys but keeps every other section under its own normalized name (trim → lowercase → '-'→'_', e.g.cline-pass → cline_pass); the two price-side normalizers keepnormalizeProviderKey, so the price gate is unchanged (guarded by a test asserting the cline_pass price is dropped while anthropic survives). (2) Tiered fallback: newresolveProviderModelMetadataEntryruns the provider-scoped lookup first (unchangedfindProviderModelRegistryEntry), then a cross-catalog by-model-id sweep layered tier-1 (catalog key in the allowlist) before tier-2, adopting a match only when exactly one catalog in the winning layer hits and stayingnullon intra-layer conflict; candidates are the raw id, a vendor-prefix-stripped id, and the display name, indexed once per entries array via aWeakMap.enrichListedProviderModelsroutes through it with no signature change (release-behavior-smoke contract stays green). (3) Cache:fetchProviderModelRegistryEntries/fetchProviderModelPricesnow fetch through a per-URLcachedFetchJson(envWORKER_MODEL_CATALOG_CACHE_TTL_MS, default 1800000ms,0disables, non-negative-integer validated) with single-flight and stale-on-error; module cache exposesresetProviderModelCatalogCacheForTests, wired into the new case andprovider-model-capability-sync.unit.case.tsbeforeEach. Prices stay out of scope — registry entries carry no price fields, so bundle-provider model prices remain the price_sync 13-provider path's job. Cross-catalog matching is per lookup form: a tier-1 conflict aborts the whole lookup, a tier-2 conflict is abandoned so the next form (raw id, vendor-stripped id, display name) is tried. The cache TTL is validated once at worker startup —readModelCatalogCacheTtlMsis exported from price-source and called increateCoreMaintenanceTasksnext toreadRetentionCleanupSettings, so a bad value fails the worker fast instead of being swallowed byPromise.allSettledat refresh time. Cross-catalog hits are stamped for observability:enrichListedProviderModelswritesresolvedVia: "cross-catalog"+resolvedFromCatalog: <catalog key>intocapability_metadata(provider-scoped/unresolved rows omit them), and both keys joined the insert-on-conflict and markAvailable managed-key strip lists so later rounds overwrite/clear them; registry-empty staleness (D6) is unchanged and preserves the stamp with the prior row. Tests: newtests/features/provider-model-metadata-fallback.unit.case.ts(10 it) attached toprovider-connection-health.unit.test.ts; newtests/e2e/provider-model-metadata-fallback.e2e.case.ts(+.spec.ts) proving tier-1 cross-catalog resolution and conflict-stays-Unknown for a qwen-keyed bundle provider..env.example+docs/ARCHITECTURE.mdupdated. The stale-on-error branch emitslogger.warn({ err, url }, "model catalog source fetch failed; serving stale payload")via a modulecreateLogger("provider")from@llmingress/logging(added topackages/providerdeps, mirroringpackages/db); the cold-cache rethrow path stays unlogged since the caller surfaces that error.pnpm run verifyEXIT=0 (471 unit + build);pnpm run verify:features23/23 zero regression. -
2026-07-22: Batch 1 Feature B shipped (20th feature,
batch1-kimi-provider) — W1 anthropic-compatible template category +kimi_coding(Kimi Coding Plan), the local Batch 1 provider plan §10 step 2. TDD red-to-green. W1 (Option A):packages/db/src/console-provider-templates.tsgains an anthropic-compatible category alongside the OpenAI-compatible one — typeAnthropicCompatibleProviderTemplateId("kimi_coding") merged intoProviderTemplateId, typeAnthropicCompatibleProviderTemplate, whitelistanthropicCompatibleProviderTemplateIds, and three accessors (list/get/is). The generic create path (normalizeProviderTemplateFormInput) needs no category switch; kimi_coding surfaces in the API Keys selector group purely by being inproviderTemplateSelectorOrder. Registry (packages/config/src/provider-registry.ts§2.1):kimi_codingbasehttps://api.kimi.com/coding/v1(/v1in the base),endpoints:{ messages: messagesEndpoint }(shared const path"messages", neverv1/messages),connectivityProbeStyle/modelListStyle"anthropic",quotaSource {supported:true},metadataKey "moonshot",creation.auth { header:"x-api-key", scheme:"" }(newanthropicTemplateAuthconst); nopriceSyncSupported;subscriptionProviderKeysuntouched. Messages egress reuses the existing anthropic adapter —buildAnthropicMessagesUrl(base)→https://api.kimi.com/coding/v1/messages,buildAnthropicProviderHeadersforcesx-api-key. Quota probe (packages/provider/src/quota-probe.ts§7):quotaProbes.kimi_coding→GET .../coding/v1/usageswithAuthorization: Bearer+Accept: application/json(a different endpoint and auth than the messages egress); newparseKimiQuota— first detail-bearinglimits[]→five_hour,usage→weekly_limit,utilization=(limit-remaining)/limit(0-1, not ×100), tolerantresetTime→ ISOresetsAt, extralimits[]ignored. Terminal pins (A+B done): registry 17→18,listProviderTemplateEntries+1, remoteKeys 14→15, supported 9→10, unsupported stays 5, remote_api_key selector group +1; subscription and both price-sync pins unchanged. Kimi's gateway messages egress is exercised by theprovider-coverage.e2e.case.tsscenario added totests/support/provider-coverage-smoke.ts(asserts/kimi/coding/v1/messagespath andx-api-key, notAuthorization); the Console E2E asserts Kimi in the API Keys group with a Messages endpoint chip and no overflow at 1280/390. §11 docs completed for both Batch 1 features:docs/PRODUCT.md(three new providers + base-path/protocol differences), the local provider quota reference doc (supported table +glm_coding/+kimi_coding, reason list +qwen_token_plan, normalization table +2, windows-only list),docs/ARCHITECTURE.md(Job Runner list +provider_quota_probe).pnpm run verifyEXIT=0 (424 unit + build);pnpm run verify:features20/20 zero regression. -
2026-07-22: Batch 1 Feature A shipped (19th feature,
batch1-glm-qwen-providers) —glm_coding(GLM Coding Plan) +qwen_token_plan(Qwen Token Plan), the two pure OpenAI chat_completions api_key providers from the local Batch 1 provider plan §10 step 1. TDD red-to-green. Registry wiring (packages/config/src/provider-registry.ts):KnownProviderKey+2,providerRegistryentries +2,knownProviderKeys+2,providerTemplateSelectorOrder+2 (qwen_token_plan after qwen, glm_coding after zai);subscriptionProviderKeysanddirectCreateOrderuntouched. glm_coding basehttps://api.z.ai/api/coding/paas/v4, metadataKeyzai,quotaSource {supported:true}; qwen_token_plan basehttps://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1, chat_completions ONLY (no responses — §0 non-target), metadataKeyqwen,quotaSource {reason:not_supported,supported:false}. Both added toopenAICompatibleProviderTemplateIdsso the existing paste-key flow surfaces them under the Console API Keys group with no new dialog/route. GLM quota reuses the exact zai probe:zaiQuotaProbeextracted into a named const referenced by bothzaiandglm_coding, soquotaProbes.glm_coding === quotaProbes.zaiand the origin-derived URL is identical (https://api.z.ai/api/monitor/usage/quota/limit; base path dropped) — asserted in a dedicated test, not the generic joinUrl loop. Intermediate pin values for the +2 mid-state landed in the same commit across the three dependency features' verifications (registry 15->17, remoteKeys 12->14, supported 8->9, unsupported 4->5, template/selector/long-tail +2); both price-sync pins and the subscription pin were left green (neither provider setspriceSyncSupported). New tests:tests/features/batch1-providers.unit.case.ts(5),tests/e2e/batch1-provider-egress.e2e.case.ts(gateway chat_completions egress to a mocked upstream for both, Bearer credential, request_activity rows), and a Console test inconsole-providers-ia-and-forms.e2e.case.ts(both templates in the API Keys group, editable base URLs, Chat Completions chip, no overflow at 1280/390).pnpm run verifyEXIT=0 (417 unit + build, coverage above thresholds);pnpm run verify:features19/19 zero regression. Deferred to the Batch-1 wrap-up (span Feature B): §11 doc edits todocs/PRODUCT.md, the local provider quota reference doc,docs/ARCHITECTURE.md. Feature B (kimi_coding+ W1 anthropic-compatible template) remainspendingfor the next segment. -
2026-07-22: Batch 1 providers — gate G1 (model discovery probe, the local Batch 1 provider plan §9-G1/§10 step 0). No real API keys; probed each provider's aligned
GET {base}/modelswithcurl. All three endpoints exist (HTTP 401 = auth-gated, endpoint present), so G1 PASSES; no static-model-list fallback capability is needed.- GLM
https://api.z.ai/api/coding/paas/v4/models→ HTTP 401{"error":{"code":"1001","message":"Authentication parameter not received in Header, unable to authenticate"}}(this segment's dependency — PASS). - Qwen
https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models→ HTTP 401{"code":"InvalidApiKey","message":"No API-key provided."}(this segment's dependency — PASS). - Kimi
https://api.kimi.com/coding/v1/models→ HTTP 401{"error":{"message":"Invalid Authentication","type":"invalid_authentication_error"}}(recorded for the Feature B segment — PASS).
- GLM
-
2026-07-21: Gateway circuit breaker boundary/negative coverage backfill (PR #43). One production change:
gatewayBreakerWindowMsnow floorsGATEWAY_BREAKER_WINDOW_MSat 1000ms (Math.max(1_000, …)) so sub-second windows can't round SamplingBreaker's per-bucket size to zero and silently void the volume gate (TDD red-to-green on"500"→1000; the threshold-clamp fix landed earlier today in 04cb7554). New tests, each written against the verified cockatiel 4.0.0 behavior: the unit suite grew 12 → 20 — success-ratio strict->boundary (equality stays closed, then tips open), half-open single-trial concurrency (excess call is queued behind the one in-flight trial, not rejected), real-failure → open-circuit → success chain interleave (a 429 credential-class key so the chain advances within the candidate), cache TTL=0 bypass, natural TTL expiry refresh, multi-database cache-key isolation, and local + OAuth-subscription open-breaker connection filtering. E2E grew 2 → 3 — streaming requests trip the breaker and are memory-filtered to the fallback (breaker E2E 3/3 on two consecutive runs). Note: two spec-provided tests encoded pinned semantics that cockatiel 4.0.0 does not exhibit (SamplingBreaker re-checks the ratio on every failure, so a front-loaded sequence at minRequests=2 opens early;executeHalfOpenqueues excess calls on the trial's decision rather than throwing) — corrected to the true behavior, no production defect. Gates:pnpm run verifyEXIT=0 (403 unit + build),verify:features18/18 zero regression. -
2026-07-21: Gateway circuit breaker shipped (18th feature). TDD red-to-green: 12 unit tests across four describes (env config + 10s connect-timeout default, per-connection registry open/threshold/min-volume/half-open/transient-retry/disabled passthrough, fallback integration converting an open circuit into a
provider_circuit_openattempt while preserving the credential probe enqueue, in-memory breaker-first credential filtering served from the TTL cache) plus 2 E2E (breaker trips to the fallback provider and recovers through half-open with in-memory filtering; transient provider errors retry the same connection before falling back).pnpm run verify(403 unit + build, EXIT=0) andverify:features(18/18 re-verified via the optimized runner — unit batch PASS 4.2s, e2e batch PASS 188.1s, zero regression, no per-feature fallback) both passed. -
2026-07-21: Provider quota review round 2 (PR #42 feedback) — three findings closed, TDD throughout. (1) The
quota_probe_enabledswitch gained its Console UI: a Pause/Resume control inside each connection's quota cell, backed bysetProviderApiKeyQuotaProbeEnabled/setProviderOAuthQuotaProbeEnabled(plain transactions; re-enabling nudgesnext_refresh_at = now()so the 5-minute scan probes promptly) andquota-probe-enable|disableactions on both credential routes; the read model exposes the rawquotaProbeEnabledalongside the compositeprobingEnabled. Debugging note: the page E2E hung 4 minutes because the toggle form was rendered as a sibling ofspan.quota-cellwhile the test locator scoped clicks inside it — the Playwright trace's single pendingFrame.clickpinned it; the form now renders asProviderQuotaCellchildren, which also made the disabled-row "no button" assertion genuinely meaningful. (2) The stale "no code implements it yet" line in the local provider quota reference doc now points at the two feature entries. (3) A successful probe that reports nothing (openrouter with no spending limit configured) renders "No quota limits reported" instead of a bare "Updated ..." line. Final:verify391 unit EXIT=0,verify:features17/17. -
2026-07-20: Provider quota hardening (post-plan review) — five fixes after live-Console review, all TDD, PR #42 opened to
dev. (1) The quota schema moved to incremental0002_provider_quota.sqland the0001baseline was restored byte-identical: editing the baseline tripped the runner's checksum guard on every already-migrated database; verified against a live pre-branch database (Applied 1 migration; skipped 1, data intact). (2)claude_code/api/oauth/usagereports utilization as 0-100 percent, unlike the 0-1 fraction in theanthropic-ratelimit-unified-*headers — a live account rendered 2400%/5300%; all eight parsers were re-audited, which also surfacedmoonshotlabeling.cnbalances USD when that host bills CNY. (3) An error state renders its reason pill alone, with no "Updated X ago" line. (4) A disabled connection (orquota_probe_enabled = false) renders "Probing paused" instead of ever-aging stored numbers — the read model now exposesprobingEnabledacross provider, connection, and probe switch — and paused entries no longer anchor a shared-balance pool. (5) Persistence is update-first: a routine refresh is a singleupdate ... wherethat never locks credential tables; only an update miss (first observation, or a deletion that cleared the row mid-probe) verifies the credential is live under a row lock before inserting, so a probe racing a deletion cannot resurrect the cleared row. Final:verify389 unit EXIT=0,verify:features17/17. -
2026-07-20: Provider quota console (Feature 2, steps 2.1–2.2) — the Providers page now renders what the probe stored. TDD red-to-green: 16 unit tests (entry-shape discrimination on a mixed claude_code array, percentage/balance/window-label/reset formatting, the three observation states, expected-vs-warning tone, shared-balance collapsing) and 2 E2E (read-model DB assertions including the never-probed connection; a booted Console asserting a window cell, a balance cell, a
not_supportedcell that carries no zero value and no danger/warn pill, a "Not yet queried" cell, one shared account pool across two connections, and no horizontal overflow at 1280 or 390).pnpm run verify(387 unit + build, EXIT=0) andverify:features(17/17, zero regression) passed. -
2026-07-20: Provider quota probe (Feature 1, steps 1.6–1.10) — Worker enqueue, job handler, maintenance scheduling, and
apps/worker/src/main.tsregistration. TDD red-to-green: 10 E2E (api_key + OAuth happy paths,not_supported/requires_separate_credentialproven not to call upstream via a throwing fetch stub, 403 →unauthorizedwith a 1h backoff, malformed base URL →probe_failedrow,quota_probe_enabled = false→ canceled with no row, expired-token refresh write-back, lost OAuth CAS race, upsert, maintenance enqueue dedupe) plus the existing 20 unit tests.pnpm run verify(371 unit + build, EXIT=0) andverify:features(16/16) passed. One collateral regression was caught and repaired: the new third core maintenance task brokeworker-maintenance.e2e.case.ts'sexecutedTasks === 2pin. -
2026-07-19: Provider metadata registry (single source of truth) — new
packages/config/src/provider-registry.tsholds all 15 providers' behavior/creation/endpoints/OAuth; provider, db, domain, gateway streaming, and Console create-dialog choices all derive from it (via the@llmingress/config/provider-registrysubpath). TDD unit (10, field-for-field vs donor + endpoints-exclude-models + unknown-key-permissive) + Console E2E (create dialog shows OpenAI Chat Completions/Responses, Claude Code Messages, no overflow at 1280/390), plus new provider-dialect and virtual-model-endpoint-routing derivation assertions.pnpm run verify(350 unit + build) andverify:features(15/15, zero regression) passed. -
2026-07-18: Agent → API Key full entity rename — DB baseline rewritten (tables
api_keys/api_key_limits/api_key_virtual_models,agent_id→api_key_id,integration_platformdropped,key_prefix/key_hashNOT NULL, 0002 folded in), packages/console/tests/docs renamed end-to-end, wire error codes and hash namespace (llmingress:api-key:v1) updated;pnpm run verifyandverify:featurespassed. -
2026-07-18: Console UI primitives P1 (EmptyState, Spinner, success Toast) — 3 features, each TDD red-to-green with focused unit + E2E (all incl. no-overflow at 1280/390);
pnpm run verifyandverify:features(13/13, zero regression) passed. -
2026-07-18: Route policy
random→load_balancerename + DB constraint removal — TDD unit (domain shuffle, code-layer accept/reject contract, migration manifest),pnpm run verify(318 unit tests + build), console-layout & virtual-model-routing E2E, andverify:features(10/10) passed. -
2026-07-17: API Key integration guidance — focused unit (5) + E2E (1, incl. 1280 two-column side-by-side, 390 stacked, no-overflow with the detail dialog open),
pnpm run verify, andverify:features(10/10, zero regression) passed. -
2026-07-17: Release-freeze guard removal — focused delivery-quality unit (26) + E2E (6) suites,
pnpm run verify(317 unit tests, coverage above thresholds), andverify:features(9/9) passed on the rebased dev baseline. -
2026-07-16: Multiple providers per provider type — provider_key uniqueness removed; provider unit+E2E suites,
pnpm run verify, andverify:features(9/9) passed. -
2026-07-16: Provider list collapse — default fully collapsed, row click toggles, Model library hidden until a provider is selected;
pnpm run verifyandverify:features(9/9) passed. -
2026-07-16: Route-policy capability mismatch clarity — informative error values + precise context display;
pnpm run verifyandverify:features(9/9) passed.
- API key request logging modes (
api-key-request-logging-modes, 37th feature, TDD red-to-green): Activity recorded metadata and never a body, so an operator debugging a bad answer had nothing to read. Each key now carriesrequest_logging_mode—default(unchanged) orfull. Migration0005_api_key_request_logging.sqladds that text column with a two-value CHECK andrequest_activity.payload jsonb; both are metadata-only DDL, and the three-way migration pin (SQL file,shippedSqlMigrationschecksum,platform-foundationmanifest) stays aligned. Storage form was the design decision: the bodies ride the activity's own row rather than a side table, so retention, uniqueness and a second insert all disappear — a captured body is deleted exactly when the request it belongs to is, large values go to TOAST, and the list query (an explicit column list) never selectspayload, so nothing detoasts on a page of 20 rows. Newpackages/gateway-runtime/src/gateway-payload-capture.tsholds the whole capture policy:captureGatewayPayloadValuekeeps a side as JSON while it fits the 1 MB byte cap and otherwise stores the serialized text cut at that many bytes — backing off to a whole-character boundary so a truncated body never ends in a broken sequence — reporting the original size and atruncatedflag either way, and returning an empty capture rather than throwing on a valueJSON.stringifycannot hold.createGatewayBoundedPayloadAccumulatorcollects a stream as it is written to the client, stops buffering at the cap while continuing to count, so memory is bounded by the cap plus one chunk and the recorded size still describes what actually streamed. The NUL rule is load-bearing: jsonb refuses a NUL inside a string and a client's JSON may legally carry one, so an unsanitized capture would not lose the body, it would lose the whole activity row — every string value and every key is rebuilt with U+FFFD before it reaches the column, and a truncated capture needs no such pass because a NUL is already six literal characters inside a JSON string. Wiring:gateway-authselects the column, narrows it with the domain guard and falls back todefaultfor a value the CHECK cannot produce;request-recordingtakesclientRequestBody+requestLoggingModeon both wrappers, captures both sides on the JSON and stream-prefailure paths, and fans the accumulator into the existingcollectChunksogateway-stream-pipelineis untouched — a stream that fails or is abandoned mid-flight still records what it sent, which is whatfullmeans.apps/gatewaygained no dependency: it reads the mode type re-exported fromgateway-runtime/gateway-auth, the same module that hands it the authenticated key. Console: the API key editor has a REQUEST LOGGING select (saved through create, edit and a refused creation's draft), and the Activity drawer renders each captured side as a collapsed<details>/<pre>with its size and cut line, closing with mode-aware copy that tells a metadata-only request why it has no bodies. Deviations from the plan, both deliberate: the plan's per-option hints became one Field hint, because a server-rendered<select>cannot show a hint that follows the selection; andpreview-console.tsnow seeds a payload on every request and opens the<details>before measuring, because a collapsed block is a block the overflow check never measures — with it open, the seeded unbroken 700-character token wraps inside the drawer at both widths. Tests:api-key-request-logging.unit.case.ts(normalize default/full/blank/invalid, the four capture behaviors incl. NUL and the multi-byte boundary, the four accumulator behaviors, and two db-backed recorder cases asserting the stored payload's content, sizes and flags — andnullfor a key that captured nothing) andapi-key-request-logging.e2e.case.ts(one gateway process, four seeded routes:fullJSON,fullstream against the fake provider's SSE mode,fullagainst a failing provider, and adefaultkey whose row stays null; plus one console run driving the editor select, reading the saved mode back from the database, and opening both drawer states). Gates: feature verification EXIT=0 (unit 15/15, e2e 2/2 in 18.9s),pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 636/636 across 35 files, build 13/13),pnpm run verify:featuresEXIT=0 — all 36 passing features re-verified (unit batch 7.0s, e2e batch 333.5s), zero regression. Ops note for the PR: afullkey writes up to ~2 MB per request and holds up to 1 MB per concurrent stream. - Capability-refusal trace (follow-up to the streaming trace fix, same branch): a request refused with 4xx
virtual_model_capability_mismatchrecorded no route in either pipeline — the refusal fired before the activity was built. Both pipelines now build the activity right after route selection and before the capability check: the JSON pipeline's existing catch returns it with the refusal, and the streaming pipeline catches the assertion to return it explicitly (releasing the concurrency lease like its sibling branches). Red first: a new tag e2e seeds a tagged candidate with maxOutputTokens 1000, sends max_tokens 5000 with the tag, and asserts 400 + zero upstream requests + recorded strategy/requestedTag/matchedTag + zero fallback_events, JSON and stream legs each scoped by client x-request-id; it polled null strategy into timeout before the fix and the tag spec is 7/7 after. - Streaming trace fix (with
playground-request-headerson the same branch): a streaming request whose every attempt failed before the first byte recorded no route at all —executeGatewayStreamingRequestbuilt itsGatewayRequestActivityRouteonly on the success path, sorequest_activityrows for failed streams carried nullroute_policy_strategy_snapshot, nullroute_reason(no requestedTag/matchedTag for a tag route) and zerofallback_events, while the JSON pipeline recorded all of it. The activity is now built right after route selection from the selected candidate, the shared attempts array and the decision — the same shape the JSON pipeline builds early — and attached to every post-selection failure return (protocol-unsupported, limits refusal, provider-error passthrough, exhaustion); the success path rebuilds it with the candidate that actually served. Red first: the tag exhaustion e2e gained a streamed leg scoped by a clientx-request-id(the earlier in-test JSON request could otherwise answer for it — the unscoped latest-row read produced a false green), which timed out polling a null strategy; green after: tag-routing spec 6/6. - Playground HEADERS rows (
playground-request-headersrevised in place, same feature id, TDD red-to-green): the free-text editor shipped the day before refused four kinds of input, and three of them were mistakes only a text field can produce — a line that is not a header, a name outside the Gateway's CORS allowlist, and a name the form itself owns. Each header is now a row: a name picker beside a value box, added with+ Add header, removed with the row's own button.playgroundHeaderOptionsholds the nine names left afterauthorizationandcontent-typeare dropped from the eleven-name allowlist copy, withx-llmingress-route-taglifted to the front so a new row opens on the header this page exists to send.parsePlaygroundHeaders,isPlaygroundSendableHeader, the reserved-name set and the header-name pattern are gone;buildPlaygroundHeaders(rows)replaces them and states one verdict per 1-based row:empty_value(blank after trimming),invalid_value(outside printable ASCII, whichfetchthrows on naming no field — and, defensively, a name the picker cannot produce), andduplicate(a header a row above already carries, where the first row keeps the value rather than a later one silently replacing it, which is what the line parser used to do). The three old wordings are deleted along with their reasons; a unit test asserts they are absent fromhelpers.ts. Unchanged on purpose:apps/gateway/andpackages/gateway-runtime/(not one byte), theplaygroundSendableHeaderscopy and the unit test that readsapps/gateway/src/cors.tsand compares itsaccess-control-allow-headersliteral, the spread order —...builtHeaders.headersahead of the form'sauthorization/content-type/generatedplayground_<uuid>x-request-id, still pinned by index in a unit test — the key-only/v1/modelsprobe, the four-stateroute tagtrace row, and the rule that this input never reaches the URL (headersTextbecameheaderRows: PlaygroundHeaderRow[], still React state only). One thing the plan did not foresee: the rows sit inside the HEADERSField, whose<label>lends its text to every labelable descendant as an accessible name, so+ Add headercould not be found by its visible text until each control was given an explicitaria-label— the row controls now carryHeader name N/Header value N/Remove header row N, which is also how the E2E drives them. The E2E rewrite keeps the hit/miss trace scenarios and the stub gateway that answersOPTIONSwith the production allowlist, and replaces the typed-refusal scenarios with the row ones: a duplicate row and an empty-value row each render their red line and leave the stub's request count unchanged when Send is clicked, and removing the row clears both and lets the request through. Known limit:x-request-idstays in the picker (the plan removes only the two headers whose values the form owns outright), so a row naming it is overridden by the generated id rather than refused — the spread order makes this safe but silent. - Playground request headers (
playground-request-headers, 35th feature, TDD red-to-green): the Playground could send onlyauthorizationandcontent-type, so the tag route strategy shipped the day before could not be tried from the Console at all. AHEADERSfield now takes one header per line;parsePlaygroundHeaders(in the Playground's ownhelpers.ts) splits on the first colon so a value keeps its own, lowercases the name, trims both sides, skips blank lines, and lets a later line replace the same name. Three refusal reasons render in red under the field as the operator types and blocksend()before the fetch:malformed(no colon, empty name, a name outside the HTTP token set, an empty value, or a value outside printable ASCII),reserved(authorization→ "sent from the API KEY field",content-type→ "fixed at application/json",x-request-id→ "generated for every Playground request"), andnot_allowed(anything the Gateway's CORS allow-headers list does not name). The reserved check runs before the allowlist check because all three names are in the allowlist — they are refused for being the form's own, not for being disallowed. The Gateway is deliberately untouched (no file underapps/gateway/orpackages/gateway-runtime/changed): CORS constrains browser pages only, and agents/SDKs/curl send no preflight, so reflecting arbitrary request headers would have loosened a real boundary to serve a console field. The price is a copy —playgroundSendableHeaders, the 11 names inapps/gateway/src/cors.tsorder — and the copy is held in place by a source-reading unit test that extracts theaccess-control-allow-headersliteral from that file and compares it toplaygroundSendableHeaders.join(", "), the same guard shaperoute-strategy-registryandconsole-route-policy-warningsalready use. Two safeguards keep a typed line from taking over the request: the parser refuses the reserved names, and the fetch spreads...parsedHeaders.headersahead of the form'sauthorization/content-type/generatedx-request-idso the form's values win regardless; every send creates a freshplayground_<uuid>and a unit test pins that order by index.headersTextis React state only and never enters the URL (/playgroundremembers no query innav-state.ts, and a header line is request input, not a view choice — restoring one would re-send it unasked). Closing the loop, the Route trace gained aroute tagrow with four states fromdescribePlaygroundRouteTag: the matched tag,<tag> → default (no match),no tag → default, and—. Its data comes fromreadConsoleActivityRouteTag(new inpackages/db/src/console-activity.ts, shaped afterreadConsoleActivityRouteCandidates), which returnsnullwhenroute_reasonis not a record or names none ofmatchedTag/requestedTag/tagFallback— a non-tag strategy and a pre-tag request get no invented verdict — and is served by/api/playground/result. The Activity drawer was left alone:formatConsoleActivityRouteReasonalready renders the decision message naming the tag. Tests:tests/features/playground-request-headers.unit.case.ts(parser branches, the three issue texts, the four tag states, the reader's four shapes, the allowlist sync guard, and the source assertions) andtests/e2e/playground-request-headers.e2e.case.ts, which boots the real Console against a stub gateway that answersOPTIONSwith the same static allowlist — a real browser will not send a custom header until a preflight allows it — and asserts the stub actually receivedx-llmingress-route-tag: fast, two consecutive sends carried distinct generatedx-request-idvalues, a fixed typed id is refused, the trace row for a seeded hit and a seeded miss, and that ax-customline leaves the stub's request count unchanged when Send is clicked. The pre-existing streaming stub's CORS fixture now allows the generated request id as the production Gateway already does. Gates: the feature's own verification EXIT=0 (unit 15/15, e2e 1/1);pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 617/617 across 34 files, build 13/13);pnpm run verify:featuresEXIT=0 — all 35 passing features re-verified (unit batch 7.9s, e2e batch 364.0s), zero regression. - Tag route strategy (
tag-route-strategy, 34th feature, TDD red-to-green): a fourthRoutePolicyStrategy,tag, routes one request to one named candidate instead of ordering the whole candidate set. Migration0004_route_policy_candidate_tags.sqladdsroute_policy_candidates.tags text[] NOT NULL DEFAULT '{}'with a no-NULL-element CHECK; tag count intentionally has no database cardinality limit; the cross-row invariants (a tag is unique inside one policy, exactly one candidate carriesdefault) stay in the application layer because the candidate write is one delete-and-rewrite transaction holding theroute_policiesrow lock and a uniqueness rule spanning rows of atext[]is not an ordinary constraint. The three-way migration pin (SQL file,shippedSqlMigrationschecksum,platform-foundationmanifest assertion) stays aligned. Domain (packages/domain/src/index.ts): all tag behavior is a registry handler, so theroute-strategy-registryguard that the domain source contains nostrategy === "still holds —RouteStrategyHandlergainedcapabilityContractScope(all_candidatesfor the three ordering strategies,selected_candidatefortag) and an optionalannotateReason({chain, context})whose result is spread intorouteReasonand handed todecisionMessage; the tag handler builds[hit, default], or[default]when the hit is the default or no tag matched, and returns an empty chain in the defensive no-default branch, so the Gateway answersprovider_unavailablewithout trying a non-default candidate. New exportsROUTE_TAG_DEFAULT,normalizeRouteTag(trim + lowercase),isValidRouteTag(/^[a-z0-9][a-z0-9._-]{0,63}$/, checked after normalization) androuteStrategyCapabilityContractScope. Gateway: headerx-llmingress-route-tagread byreadGatewayRequestedRouteTag(first value, normalized, empty → undefined; an unusable tag is not a refusal — it matches nothing and the default candidate answers), added toproviderRequestHeaderDenylistso it never reaches an upstream and to the CORS allow-headers; threaded fromapps/gateway/src/main.tsthrough the three protocol executors into bothselectRouteAttemptscall sites, and echoed onGatewayRequestMetadata.requestedTagviawithGatewayRequestedRouteTag. The capability check moved after selection at both call sites (selection is a pure function, so nothing is spent ordering a chain the check then refuses) and dispatches on the scope:all_candidateskeeps the shared contract,selected_candidatebuilds the contract fromchain[0]alone, so a tagged candidate that cannot serve the request answers 4xxvirtual_model_capability_mismatcheven when the default one could — which is why a tag policy's deliberately unequal candidates no longer have to agree. Console (packages/db/src/console-route-policies.ts):routePolicyStrategies+tag(the strategy-invalid message now derives from the array); the form carriescandidateTags(one comma-separated list per candidate) normalized intostring[][]withroute_policy_tag_invalid/_duplicate/_missing/_default_required, cleared for non-tag strategies;assertRoutePolicyCandidateCapabilityContractruns only forall_candidatesscope; candidate insert/select and the list query carrytags; and a newbuildTagRouteCoverageWarningscompares the default candidate against every tagged one on context window, output ceiling, in/out modalities, function calling and reasoning (unknown values skipped, never guessed) and joins the existing price/availability warnings inrouteWarnings. UI: the strategy note names the header and the fallback rule (TS forces bothRecord<RoutePolicyStrategy, string>maps), the editor swaps its two candidate headings for the tag semantics and emits onecandidateTagsfield per selected candidate (a hidden empty one for other strategies) in the same order as theproviderModelIdshidden inputs — the API route reads it with a newreadAlignedTextValuesthat keeps blank entries so a blank field cannot shift a tag onto another model; the Virtual Model detail gains a TAGS column for tag routes (default in amber) and, for the first time, renderspolicy.routeWarningsat all, which is what makes the soft coverage warnings (and the pre-existing price/availability ones) visible. Deviation from the plan: it specifiedreadTextValuesforcandidateTags; that helper drops empty strings, which shifts the tag/model pairing and misattributes the refusal, so the positional reader was added instead. Known limit (commented in the editor): the tag field cannot joinPRESERVED_EDITOR_FIELDSbecause repeated names read back as aRadioNodeList, so reordering, adding or removing a candidate and switching strategy drop unsaved tag text — the same as NAME and DESCRIPTION under plainLinknavigation. Tests: tag cases inroute-strategy-registry.unit.case.ts(three decision messages, normalization, hit===default, defensive no-default, contract scope per strategy, tag validity), a newtag-route-policy.unit.case.ts(each error code, non-tag clearing, six coverage dimensions + unknown-skip + full coverage + no default), a fail-closed registry regression for a filtered snapshot with no default, the chain[0]-scoped gateway check invirtual-model-capability-contract.unit.case.ts, header hygiene + CORS ingateway-request-hygiene.unit.case.ts, render-point assertions inconsole-route-policy-warnings.unit.case.ts, and E2E coverage behindtests/e2e/tag-routing.e2e.spec.ts— gateway (tag hits its candidate and the header does not egress; no tag → default; unknown tag → 200 withrequestedTag/tagFallbackinrequest_activity.route_reason; a failing tagged candidate falls to the default and stops there with a third candidate untouched and 2fallback_events; both failing = exactly 2 attempts; a filtered no-default snapshot returnsprovider_unavailablewith zero upstream attempts; a stream that fails after its first byte is never replayed) and Console (duplicate tag refused with nothing written, a 33-tag candidate saves successfully, save with one default, TAGS + the warning list visible at 1280, no page overflow at 390).tests/support/gateway-route-seed.tsgainedstrategy/tagsand aseedGatewayRouteCandidatehelper. Gates: the feature's own verification EXIT=0 (unit 106/106 across 4 files, e2e 14/14 in 58.4s);pnpm run verifyEXIT=0 (lint clean, typecheck 13/13, unit 602/602 across 33 files, build 13/13);pnpm run verify:featuresEXIT=0 — all 34 passing features re-verified (unit batch 10.0s, e2e batch 361.3s), zero regression. - Batch 2 Feature B (
provider-minimax-coding-plan): completed the MiniMax Coding Plan subscription — Anthropic messages egress, the coding_plan quota probe, and refresh reuse (the local Batch 2 device-code plan steps 2, 4, 5), on top of Feature A. R-H pre-verification against the third-party reference source (cloned to scratchpad only): the endpointGET {origin}/v1/api/openplatform/coding_plan/remains, Bearer-only auth (no GroupId/extra header — the plan's open question resolved to none),base_resp.status_code != 0= error, and the REMAINING-percent-inverted-to-utilization direction all confirmed; the one correction to the plan's "mirrorparseMinimaxQuota" note is that the coding_plan payload nests the windows inmodel_remains[]undermodel_name === "general"(video skipped) and gates the weekly window oncurrent_weekly_status === 1(status 3 = no weekly cap), soparseMinimaxCodingPlanQuotais an independent parser, not a token_plan reuse. Egress:buildMiniMaxSubscriptionHeaders(Bearer +anthropic-version, stripsx-api-key, no stainless/beta/UA) is shared bycreateMiniMaxProviderAdapter(adapters/subscription.ts; body =buildAnthropicMessagesPayload+system: withClaudeCodeSystemPrompt; URLjoinUrl(base, "messages")— noappendV1Path) anddialects.minimax_coding(defaultjoinUrlbuild,transformBodyinjects the same identity block).gateway-messagesdispatches theminimax_anthropicadapter (adapter selection +planCandidates.supportedfilter both updated) and readsproviderApiKey.baseUrl ?? candidate.baseUrl; the per-tokenresource_urlfrom the OAuth blob rides on the newFallbackProviderApiKey.baseUrl(subscription branch pushestoken.resourceUrl, candidate assembly takesprimaryKey.baseUrl ?? credential.baseUrl, and streaming'sbuildStreamingAttemptCandidatelets the base follow the rotated key). Quota:quotaProbes.minimax_codingderives the URL from the base origin (likezai) + Bearer with the independent parser, and the registryquotaSourceflips to{ supported: true }in the same commit (registry snapshot + provider-quota supported/unsupported/transport/remoteKeys16 assertions updated). Refresh: no new code — a unit assertsisSubscriptionProviderKey("minimax_coding")(the gateway refresh-loop gate) and thatrefreshProviderOAuthToken("minimax_coding")round-tripsexpired_in→expiresAtandresource_url→resourceUrlthrough the shared path. Tests: dialect + adapter cases inprovider-dialect.unit.case.ts(provider-management suite), quota parse/transport + refresh cases inprovider-quota.unit.case.ts, and a newtests/e2e/gateway-minimax-egress.e2e.case.tsthat seeds a subscription provider +provider_oauthand asserts — non-streaming AND streaming — Bearer, the injected identity system block, nox-api-key/stainless/anthropic-beta, URL.../anthropic/v1/messages, and per-tokenresource_urloutranking the registry base.pnpm run verifyEXIT=0;pnpm run verify:features22/22 zero regression (unit batch 5.2s, e2e batch 192.1s, no per-feature fallback). Review follow-up (3 should-fix, TDD red-first): (1)parseMinimaxCodingPlanQuotathrows on a non-zerobase_resp.status_codeso the sharedparsed()catch returnsprobe_failedwith thestatus_msg— a stale token no longer renders as empty quota (probe-level assertion inprovider-quota.unit.case.ts); (2)attachGatewayProviderCredentialssetscandidate.baseUrltocredential.baseUrlonly (the provider-base anchor), so a bare rotated OAuth connection resolves via the call sites'providerApiKey.baseUrl ?? candidate.baseUrlto the provider base instead of the primary connection'sresource_url; non-oauth/claude_code/codex keys carry nobaseUrland degrade to the original behavior (two-connection base-anchor assertion ingateway-request-hygiene.unit.case.ts); (3)refreshProviderOAuthTokenWithLockpersistsrefreshed.resourceUrl ?? current.resourceUrl, so a refresh response withoutresource_urlkeeps the prior per-token base (refresh-preservation assertion checking the returned blob and the read-back ciphertext). Post-fixpnpm run verifyEXIT=0 (445 unit + build) andpnpm run verify:features22/22 zero regression. Pre-merge review round 2 (PR #45, 4 fixes, TDD red-first): (1) the local provider quota reference doc — thecoding_plan/remainsbullet now says the endpoint rejects API keys (Batch 1 conclusion) but accepts a Coding Plan subscription OAuth Bearer; the availability table went Ten -> Eleven with aminimax_codingrow; (2) both soft-delete paths (deleteProviderOAuthConnection,deleteProvidercascade) now null the three device pending columns, symmetric withcompleteProviderOAuthConnection; (3) security —sanitizeProviderOAuthResourceUrlvalidates the upstreamresource_urlat capture (poll + refresh wrappers): kept only when https and its host is in the provider's own OAuth-endpoint/registry-base host set (derived from config, provider-agnostic), else dropped without failing the exchange (egress falls back to the registry base; a dropped refresh value keeps the prior good base); (4) security —rewriteDeviceVerificationUrithrows on a non-https scheme so ajavascript:/http:verification_urifails the start flow instead of reaching a Console href.pnpm run verifyEXIT=0 andpnpm run verify:features22/22 zero regression. Pre-merge review round 3 (2 fixes, TDD red-first): (a) the device double-complete race (two tabs polling) is closed lock-free —completeProviderOAuthConnectiongainedonlyIfPending(conditionalWHERE ... AND (NOT $8 OR completed_at IS NULL); a 0-row result returns the already-completed row idempotently without overwriting the token or holding a lock across the upstream call), wired into the device poll path only, with the authorization_code path unchanged; (b)normalizePollIntervalSecondsclamps the normalized interval to [1, 60] seconds against extreme upstream values.pnpm run verifyEXIT=0 andpnpm run verify:features22/22 zero regression. - 2026-07-22: Candidate feature recorded (
provider-model-metadata-fallback, pending, not started): manual testing of the Qwen Token Plan bundle showed cross-vendor model ids (deepseek/glm/wan) render all-Unknown because metadata resolution is provider-scoped; the entry sketches a global by-model-id catalog fallback with ambiguity guard. - Batch 2 Feature A (
provider-oauth-device-code): added the device/user-code + PKCE + polling OAuth flow (MiniMax shape, not RFC 8628) and closed the MiniMax Coding Plan login loop, per the local Batch 2 device-code plan (steps 0, 1, 3). Migration0003_provider_oauth_device.sqladdspending_user_code/pending_verification_uri/pending_interval_seconds+ aflow_typediscriminator (defaultauthorization_code, CHECK in{authorization_code, device_code}) toprovider_oauth; the three-way migration pin (SQL file,shippedSqlMigrationschecksum,platform-foundationmanifest assertion) stays aligned.ProviderOAuthConfigbecame a presence-discriminated union (authorization-code carriesauthorizeUrl/redirectUri; device-code carriesdeviceCodeUrl/defaultPollIntervalSeconds, mutually exclusive via?: never) so existing OAuth entries' snapshots stay byte-identical;buildProviderOAuthAuthorizeUrl/exchangeProviderOAuthCodenarrow viarequireAuthorizationCodeOAuthConfig. New registry providerminimax_coding(subscription; basehttps://api.minimax.io/anthropic/v1;subscriptionAdapter: minimax_anthropic;quotaSourcenot-yet-supported — Feature B flips it with the coding_plan probe) plus carrier/order/type wiring and aproviderUsesDeviceCodeOAuthhelper. Engine:requestProviderOAuthUserCodeposts the PKCE challenge and rewrites the verification host (www.minimax.io→platform.minimax.ioon/oauth-authorize);pollProviderOAuthUserCodeTokenmaps bodystatus(error/non-2xx→error, non-success→pending, success→normalize);normalizeTokenBodygained anexpires_in ?? expired_infallback (poll + refresh) and capturesresource_urlinto the token blob's new optionalresourceUrl, round-tripped through bothreadProviderOAuthTokenBlobsites. Storage/service: device pending write + start-time clear-old-rows +pollProviderOAuthDeviceAuthorization(local expiry, one upstream poll, complete);completeProviderOAuthAuthorizationrejects device rows. Console:action=poll(JSON) route + a one-time client dialog (provider-oauth-device-dialog-client.tsx, query-param driven) showing the code + verification URI, polling to a completed state. Feature B (egress adapter/dialect/gateway dispatch, per-token baseUrl consumption, coding_plan quota probe) is explicitly out of scope.pnpm run verifyEXIT=0 (lint+typecheck+436 unit+build);pnpm run verify:features21/21. - The Gateway request path now carries an in-memory per-connection circuit breaker.
packages/gateway-runtime/src/gateway-circuit-breaker.tsis a cockatiel 4.0.0 (SamplingBreaker) registry keyed by connection: a rolling error-percentage window with an exact minimum-request volume gate opens the circuit,halfOpenSamplingtrials probe recovery, andonBreak/onReset/onHalfOpenlog each transition. Every provider attempt inexecuteProviderFallbackAttemptsis routed through the registry; aBrokenCircuitErroris turned into a syntheticprovider_circuit_openattempt that advances to the next credential rather than calling upstream, while real failures (credential, quota, 4xx, 5xx, network, timeout) still feed the breaker. Breaker-first connection filtering runs during credential assembly ahead of theprovider_health_summarycheck, and those summary reads are now TTL-cached in memory keyed bydatabaseUrl(GATEWAY_HEALTH_SUMMARY_CACHE_TTL_MS, default 5s,0disables). Transient failures (network/timeout/5xx before first byte) retry the same connection through a wrapped cockatiel retry (GATEWAY_PROVIDER_RETRIES, default 2 extra retries after the initial call;GATEWAY_PROVIDER_RETRY_INITIAL_DELAY_MSbackoff base) — this changed the streaming 5xx fallback e2e call-count expectation from 1 to 3. The streaming connect timeout default drops 30s → 10s. New env knobs:GATEWAY_BREAKER_ENABLED,GATEWAY_BREAKER_ERROR_THRESHOLD_PERCENT,GATEWAY_BREAKER_WINDOW_MS(whole-second, ≥1000),GATEWAY_BREAKER_MIN_REQUESTS(exact per-window count),GATEWAY_BREAKER_HALF_OPEN_AFTER_MS,GATEWAY_BREAKER_HALF_OPEN_CALLS. Breaker state is memory-only and resets on process restart; the existing worker-probe health system and thegateway_credential_errorenqueue are unchanged. - The Providers page now shows each connection's stored upstream quota (Feature 2 of the local provider quota reference doc).
packages/db/src/console-provider-quota.tsis the read model:withPooledPostgresClient, the same three-kindprovider_connectionsCTE asconsole-provider-health.ts, a left join toprovider_quota_summary, camelCase output withDate | null, ordering in SQL. A connection with no summary row yieldsentries: [],errorCode: null,observedAt: null, which is what makes "not yet queried" distinguishable from anerror_codemeaning "cannot be retrieved". Rendering splits acrossapps/console/src/app/_lib/provider-quota-format.ts(pure, unit-tested view builder) and a new Quota column inproviders-client-section.tsx;providers-section.tsxonly loads the data, because the connection table lives in the client component. Entries are discriminated byisWindowEntry/isBalanceEntryfrom@llmingress/domain/quota, never by a type tag, so oneclaude_codeconnection renders its windows and its overage balance.not_supportedandrequires_separate_credentialrender as a neutralpillwith a plain-language reason and no zero value; onlyprobe_failed/unauthorizedgetpill--warn, and nothing in the quota column ever usespill--danger— a quota probe failure is not a connection-health failure. A live-but-tiny window renders<1%rather than rounding to0%. Balance amounts stay the stored decimal string (never parsed to a number). Where several connections of one Provider report an identicalcurrency+total, the amount is lifted to a single provider-level "shared across N connections" line and removed from the per-connection rows, so N credentials on one account never read as N pools. - Worker now probes upstream Provider quota on a schedule and writes
provider_quota_summary(Feature 1 of the local provider quota reference doc).packages/db/src/provider-jobs.tsgained a quota-specific connection enumeration (listDueProviderQuotaProbeConnections) whose predicate carriesquota_probe_enabled = true, plusenqueueProviderQuotaProbeJob. The plan asked for the flag to go on the existing shared readiness predicate; that would have silently stopped connection-health probing and model refresh for any connection that merely opted out of quota probing, so the quota path got its own enumeration instead.packages/worker-runtime/src/worker-provider-quota-probe.tsis the handler: a Provider whosequotaSourceis unsupported — and anylocalProvider, which has no billing — writesentries = []with its reason and never calls upstream;quota_probe_enabled = falsereturns{ canceled: true, reason: "quota_probe_disabled" }and writes no row; OAuth expiry triggers a refresh with a ciphertext-guarded compare-and-swap. A probe that throws (a malformed stored base URL reachesnew URL()outsidezai's internal try/catch) is recorded asprobe_failedrather than allowed to fail and retry the job, because §3.7 requires a row to always exist. The handler deliberately does not carry over the health module's "success deletes the summary row" branch. Scheduling lives in aprovider-quota-probe-enqueuecore maintenance task (5 min) rather than in the handler, so a chain broken by a lost job self-heals. - Consolidated every provider's static metadata into a single source of truth,
packages/config/src/provider-registry.ts: for the 15 known provider keys it holds the behavior descriptor, the template/direct creation shape, the routable endpoint face (chat_completions/responses/messages) kept separate from the inward model-list catalog (modelListEndpoint), and the two OAuth subscription configs. Everything else derives from it —packages/provider(resolveProviderDescriptor, dialectsupportsPathSuffix, OAuth config reads, subscription URL path literals, adapter + connectivity + model-list URLs viadefaultEndpointPathByProtocol/defaultModelListPath),packages/db(provider templates + selector groups,ProviderType, andlistProviderRouteEndpointProtocols), the@llmingress/domainrouteEndpointProtocolsenum,packages/gateway-runtimestreaming path suffixes, and the Console provider-create choices. The provider create dialog now renders the selected choice's supported routable endpoints as read-only chips (Chat Completions / Responses / Messages — never the models catalog), and the two duplicate protocol-label maps were merged ontoformatRouteEndpointProtocolLabel. Behavior fix: unknown provider keys are now permissive across all three routable protocols (previously an empty set). No schema changes. Registry symbols are imported from the@llmingress/config/provider-registrysubpath, which stays free ofnode:fsso the client dialog and@llmingress/domaincan import it. - Renamed the product entity Agent → API Key across the whole monorepo (the gateway credential was only ever a key). DB:
agents→api_keys,agent_limits→api_key_limits,agent_virtual_models→api_key_virtual_models,agent_id→api_key_id,request_activity.agent_key_prefix/agent_name_snapshot→api_key_prefix/api_key_name_snapshot;integration_platformcolumn dropped;api_keys.key_prefix/key_hashare nowNOT NULL. TS symbols (ConsoleApiKey,listApiKeys,ApiKeyLimit*,GatewayAuthenticatedApiKey, …), console routes (/api-keys,/api/api-keys,/api/api-key-limits), CSS classes (api-key-*), query params, and console error codes renamed. Gateway wire codesmissing/invalid/disabled_api_keyand hash namespacellmingress:api-key:v1updated (byte-identical in db + gateway). Integration-guide platform enum is now a UI-localIntegrationPlatformunion with no DB column. Local dev DB must be reset (docker compose down -v && docker compose up -d) because the baseline was rewritten. - Console UI primitives (P1, from the namethatui pattern audit): a shared
EmptyStateprimitive (_components/empty-state.tsx) replaces the ad-hoc<p>No…</p>/colspan empty cells across providers/models/overview/activity/limits; an accessibleSpinner(_components/spinner.tsx,role=status,prefers-reduced-motionaware) marks Playground model-loading and send in-flight states;ConsoleMutationToastgained a success tone (.console-mutation-toast--success, ok tokens) andConsoleMutationFormasuccessMessageprop that fires a self-dismissing success toast on the stay-on-page refresh path (provider API key enable/disable toggle opts in). Skeleton was deferred (no Suspense boundary to consume it); Tooltip and overflow-menu Dropdown remain future work. - Renamed route policy strategy
random→load_balance(display label "Load Balance"); serialized identifier changed end-to-end (domain union + dispatch key, db const/validation, console UI labels + strategy default, activity label map). The DBroute_policies_strategy_checkCHECK constraint was dropped — the allowed-value set now lives only in the code layer (routePolicyStrategies/isRoutePolicyStrategy). The former0002_route_policy_load_balance.sqlwas folded into0001_core_baseline.sqlduring the API Key rename (constraint simply absent from the baseline; no back-instances to backfill). Decision message reads "load balance route for …". - API Key Integration Guidance shipped (10th feature): Integration Platform UI removed and the platform enum is now a UI-local vocabulary (the
integration_platformcolumn was dropped in the API Key rename); the API key detail dialog is a wide two-column view (fields/limits · endpoint groups) with full-width 8-platform guide tabs shared with the create flow and one-time page. - Integration guides fact-checked against official docs (2026-07-17): GitHub Copilot rewritten (VS Code Custom Endpoint + Copilot CLI env vars), Cursor reachability and chat-only caveats, OpenCode
/connectdetail +$schema, Claude CodeANTHROPIC_DEFAULT_HAIKU_MODEL, and per-tool endpoint-protocol notes; other guides verified accurate. - Removed V1 release-freeze guards: feature-list/suite-mapping pins, progress.md line cap, single-migration/24-table freeze, retired-surface absence checks, PostgreSQL image pin, Compose boot check. Real behavior tests remain under the renamed
delivery-qualitysuites (9th feature renamed fromrelease-guards). - Providers are no longer unique per provider type/key: users can create any number of same-type providers with any display name and base URL; provider type only determines the wire protocol.
- Provider list expansion is driven only by the
selectedURL param:/providersdefaults to fully collapsed, clicking the expanded row collapses it, and the Model library card renders only while a provider is selected. - Renamed secret env/config from
MASTER_KEY/MASTER_KEY_FILEtoENCRYPTION_KEY/ENCRYPTION_KEY_FILE. Provider secret crypto remains AES-256-GCM. /v1/embeddingsremains retired; embedding model metadata remains supported.- The API key created and detail dialogs render one shared
ApiKeyDetailPanelsin the sameapi-key-view-dialogshell; only the key field differs (plaintext with a hide toggle after creation, stored prefix with copy only in the detail view).createApiKeyWithSettingsreads saved limits back in-transaction so the created dialog can render Budget/RPM/TPM/Token.
- None.