Skip to content

Latest commit

 

History

History
126 lines (114 loc) · 158 KB

File metadata and controls

126 lines (114 loc) · 158 KB

LLMIngress Development State

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.

Feature domains

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.

Baseline

  • 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.sql and new migrations are expected post-V1.
  • Deployment: Docker Compose with two containers — one multi-role app (all: migrate then Gateway/Console/Worker) plus PostgreSQL.

Latest verification

  • 2026-08-01 (least-time-route-strategy CI fix, PR #70 follow-up): The two restart-style E2E cases (:475/:576 in tests/e2e/least-time-routing.e2e.case.ts) flaked ECONNREFUSED on CI (3/3 retries) and ~1/6 locally. Root cause: the second startGatewayProcess in each test reused the same port as the first, so waitForGateway'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-phase startGatewayProcess calls now use a fresh getFreePort() result (restartPort) instead of reusing port, so the probe can never be answered by a stale connection to the dying instance. No change to tests/support/gateway-process.ts or 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 lint EXIT=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 on feat/least-time-route (worktree, based on origin/dev). This session executed M7-M8; M1-M6 (migration 0007_least_time_route_latency.sql, the domain least_time handler with EWMA tie-bucketing + exploration, the gateway-route-latency stats singleton, fallback-chain sampling wired into both protocol pipelines, main.ts start()/stop() lifecycle wiring, and Console strategy enumeration) landed in prior sessions and were already green (676/676 unit, build 13/13). M7: added delay_ms (json branch) and first_byte_ms (stream branch) timing controls to tests/support/fake-provider.ts, and a five-scenario tests/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-candidate fixed route first — route_latency_stats keys on provider_model_id, so a sample earned under one route policy carries over when the same provider_model_id is reused as a least_time candidate — keeping delay_ms/first_byte_ms genuinely 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" seeds route_latency_stats directly (the same technique already used for rate_limit_windows/budget_periods E2E 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 — polling route_latency_stats until 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.ts EXIT=0, 20/20. M8: docs/ARCHITECTURE.md (strategy list + restart-survives-in list), docs/PRODUCT.md (routing section), README.md, and docs/README.zh-CN.md each gained one least_time mention matching their existing style; feature_list.json gained the least-time-route-strategy entry (verification string uses platform-security.unit.test.ts as the real aggregate entry point for the migration-manifest check, since platform-foundation.unit.test.ts is not a file — platform-foundation.unit.case.ts is imported by platform-security.unit.test.ts). Final gates: the feature's own verification string run verbatim EXIT=0 (unit 126/126, e2e 18/18); pnpm run verify EXIT=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:features EXIT=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_id warm-up technique above (explore percent stays 0 throughout, as specified); the fallback scenario's failing candidate uses mode=unsupported-parameter rather than mode=error, because mode=error's 503 gets retried by the circuit breaker's retry policy (an early run observed 3 requests instead of 1), while unsupported-parameter's 400 falls back after exactly one attempt, matching weighted-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 on feat/weighted-route-strategy (worktree, based on origin/dev). M1: migration 0006_route_policy_candidate_weights.sql adds route_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.00 is a real fallback-only configuration. Three-way pin (SQL, shippedSqlMigrations checksum, manifest test) confirmed red (5 loaded vs 6 expected) then green. M2: domain gains "weighted" on RoutePolicyStrategy, 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 in candidateOrder. The structural guard (no strategy === " in packages/domain/src/index.ts) still holds. M3: normalizeRoutePolicyCandidateWeights validates each weight (/^(?:0(?:\.\d{1,2})?|1(?:\.0{1,2})?)$/) and sums on integer hundredths to avoid float error, throwing route_policy_weight_invalid / _missing / _sum_invalid; wired through listRoutePolicies, writeRoutePolicyCandidates, and the virtual-models API route via readAlignedTextValues (not readTextValues, which drops blanks and misaligns candidates). M4: GatewayRouteCandidateSnapshot and the snapshot SQL/mapping carry weight; the two selectRouteAttempts call sites needed zero changes (snapshot passed through). New weighted-routing.e2e.case.ts proves 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.ts regressed clean (7/7) after the seed-helper signature change. Backfilled weight: null on 7 GatewayRouteCandidateSnapshot test fixture factories per the plan's red line, though tests/ is not covered by any tsconfig in pnpm run typecheck/typecheck:scripts in this repo today — applied as compliance, not because a type error was observed. M5: dialogs.tsx gains an index-aligned candidateWeights field per selected candidate (hidden-input fallback for non-weighted strategies, matching the existing candidateTags pattern) plus weighted-specific CANDIDATES/SELECTED copy; detail.tsx gains 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 verify EXIT=0 (lint clean after lint:fix on 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.ts regression EXIT=0 (7/7); pnpm run verify:features EXIT=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 in RoutePolicyCandidateRow. 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 every onInput unless 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, e2e toHaveValue("0.12") red against "0.12222" then weighted-routing.e2e.spec.ts 3/3), pnpm run verify EXIT=0. 2026-08-01 streaming e2e follow-up: added two real-gateway streaming cases to weighted-routing.e2e.case.ts pinning 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 verify EXIT=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 against playground.tsx source passed 132/132, pnpm run verify passed EXIT=0 (lint clean, 13 typechecks, 621/621 unit across 34 files, 13 builds), and pnpm run verify:features passed 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-activity unit 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-triggered request_activity.latency_ms integer 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 verify passed lint, 13 package typechecks, script typecheck, 574/574 unit tests, coverage, and all 13 builds; pnpm run verify:features passed its optimized unit batch and 150-test E2E batch, re-verifying all 33 passing features without fallback.

  • 2026-07-29 (.env.example configuration audit): reorganized the example into shared endpoints/security, Docker Compose, local init.sh/pnpm dev, tests, Gateway, Worker, and Provider OAuth sections. Preserved the operator's in-progress POSTGRES_PORT addition 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 host DATABASE_URL/TEST_DATABASE_URL relationship to POSTGRES_PORT is 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.sh initially exposed a real context collision after .env.local became visible to Compose: its host DATABASE_URL (127.0.0.1) replaced the container-network default and migration exited ECONNREFUSED. Docker now reads the independently overridable COMPOSE_DATABASE_URL, defaulting to the postgres:5432 service, while host processes retain DATABASE_URL. The second live check found Turbo strict env filtering values loaded by root pnpm 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.sh always passes .env and conditionally passes .env.local afterward to Compose; root pnpm dev now runs through the same loader already used by init.sh; and the non-Docker public Gateway URL derives from the effective GATEWAY_PORT unless GATEWAY_URL is 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.local present and absent; Shell syntax, Compose config and Shell override expansion, Biome, config/script typechecks, JSON, and diff checks passed. Full verify and verify:features were not run.

  • 2026-07-29 (published Gateway URL derivation): Compose now derives the Console-facing GATEWAY_URL from GATEWAY_PORT (4567 expanded to http://127.0.0.1:4567 in the config check), so Playground model discovery reaches the branch's published Gateway port instead of a stale hard-coded 4000. An explicit GATEWAY_URL remains 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. Full verify and verify:features were not run.

  • 2026-07-29 (branch-scoped Compose projects): Docker deployment identity now follows the checked-out Git branch instead of the worktree directory. main stays on project llmingress; every other branch uses llmingress-<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. Full verify and verify:features were not run; no second live stack was started because the existing stack owns the default ports.

  • 2026-07-28 (Docker deploy repair): ./scripts/deploy.sh now 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 with getaddrinfo ENOTFOUND postgres. Verification was intentionally focused: platform-security unit tests passed 33/33 with TEST_DATABASE_URL; PostgreSQL was then forcibly disconnected from the project network and the updated deploy script recreated both containers, restored the postgres DNS alias, preserved all three applied migrations (Applied 0 migrations; skipped 3), and returned Gateway readiness plus Console HTTP 200 with Worker started. Full verify and verify:features were 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 lg and allowing the Models filter/paging row to wrap; the provider detail no longer forces a mobile left gutter. Virtual Model name conflicts now report field: name, so the refused input receives aria-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 Overview 7d + Hide and Usage 30d flow 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 verify passed (lint, 13 package typechecks, script typecheck, 571/571 unit tests, 13 builds); pnpm run verify:features passed 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.sh startup gate after running it in the canonical redesign worktree. Biome first refused 19 accumulated formatting/import-order errors, then TypeScript found setApiKeyLimitsEnabled reading rowCount from the deliberately narrower ConfigPublishQueryResult; the update now uses returning id and rows.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.sh then 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 DESC in 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 window and 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 Overview 7d + Hide plus Usage 30d. 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. MutationForm now has an explicit errorPresentation="toast" mode that announces the server error or network fallback through ToastHost; the new red tone uses the danger border, red message text, and role=alert, while success remains role=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 of ToastHost for 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 MutationForm was 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 SyncedSearchInput and SyncedSelect also 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 plus Last 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-Headers response 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 fd80536b already removed the Disable limits instead action 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 models was 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 and Refresh models remains 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 while limits_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. cdbb3b6a correctly made the inline error and compact action forms shrink, but left overflow-x-auto on 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 82090587 on 2026-07-25: /v1/models was 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 3565b18f on 2026-07-27; the underlying save/runtime equality contract began in cf1ed612 on 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 — MutationForm roots 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 both Refresh models and Re-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 createApiKeyWithSettings independently skipped the rule write when limits_enabled was false. Both now preserve the submitted rules; limits_enabled controls enforcement only, matching the architecture and product invariants. A unit guard covers both ownership layers, and api-key-editor.e2e creates 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 verify EXIT=0 (lint clean, typecheck 13/13 + scripts, unit 551/551, build); pnpm run verify:features EXIT=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 from providerOAuthId — does not work: listProviderOAuthMetadata selects where completed_at is not null, so a pending row is not in the list the dialog reads (model.ts has a branch for authorization pending that the data never reaches). The values come back on the URL instead, in providerOAuthLabelValue and providerOAuthPriorityValue — parameters the start redirect has always written and nothing has ever read. ConnectionIdentityFields takes the two values rather than a connection now, so each screen names where its defaults come from. A first draft of this fix also carried enabled and quotaProbeEnabled through, on the strength of a probe that seeded quota_probe_enabled = false on 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.e2e seeds 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=new as 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 key and every subscription's + Authorize token opened "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. closeHref cleared the candidate parameters but not editor_name, editor_description, protocol or editorStrategy, and buildHref copies 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-params names the empty set, the shape the grants picker already uses. A refused route left a rename committed. updateWithRoute was 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. updateVirtualModelWithRoute publishes once; updateVirtualModelWithClient and updateRoutePolicyWithClient are 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 why getProviderDependencyImpact is now read for that confirm too. Found while fixing it: that impact named routes by description, 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 by name; the assertion that pinned the description was updated with the reason. Gates: pnpm run verify EXIT=0 (unit 550/550, build); pnpm run verify:features EXIT=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 reqs and unavailable / 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 rendered formatCost(null) for any absent source, so reconciled read — · 0 reqs, and it labelled the last row with the raw enum unavailable, dropping the half of the label that says what that dash means. COST_SOURCES now carries a label and a metered flag 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. standaloneThemeHead emitted a fonts.googleapis.com preconnect and stylesheet while the console proper self-hosts through next/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 on getByText('$0.00 · 0 reqs') and passes when restored. Same trap as the fabricated eligible: false fixture the round before: a red or a green that comes from the wrong cause proves nothing. Gates: pnpm run verify EXIT=0 (unit 550/550, build); pnpm run verify:features EXIT=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. 3565b18f said 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 on eligible === false. Reading the router: buildRouteAttemptCandidates only sorts, nothing upstream drops candidates, and selectRouteAttempts writes eligible: true, reasons: [] for every candidate — eligible: false appears 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, in fallback_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). readConsoleActivityRouteCandidates stopped returning eligible and reasons from 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) === 0 rendered "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 answers null rather 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 candidate eligible: 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 verify EXIT=0 (unit 549/549, build); pnpm run verify:features EXIT=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. 9f05a927 diagnosed that an empty value cannot cross a query string and built the none sentinel for grants, then wrote the limit draft in the same file with if (value): an emptied field has nothing to write, so its parameter was absent, and the reader's draft(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 survives buildHref, 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 own required check, refused by the route) and holds all five empty on the way back; reverting only the read side turns it red with Budget USD: expected "" received "25". The assertion that missed this checked only fields that had values. Gates: pnpm run verify EXIT=0 (unit 549/549, build); pnpm run verify:features EXIT=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.unit plus 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 the modelPageSize bound (122 seeded models, 100 rendered). Six were real. Edit-path enforcement: the create path's warn_only was pinned and the edit path's was not, so a save that rewrites every rule could have reverted the policy unnoticed; api-key-editor.e2e now switches an existing key to block, 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, so renderOneTimeProviderKeyPage moved into provider-keys/_created-page.ts beside the api-keys one, and its contract now holds that the pasted secret appears once, that every data-copy target exists, that the theme rule and pre-paint bootstrap are present, and that hostile input is escaped. loading.tsx presence: console-spinner.unit walks 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.e2e seeds a serving, a failing and a switched-off connection and holds 2 serving · 1 failing · 1 disabled, the split this branch introduced and left unguarded. The sign-in status line: consoleStatusLine moved to its own module (a .tsx file 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 — copyText awaited navigator.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, while element.click() from page.evaluate does, 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 verify EXIT=0 (lint clean, typecheck 13/13 + scripts, unit 549/549 across 33 files, build); pnpm run verify:features EXIT=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. providerOAuthExpiresAt was the one OAuth parameter closeHref did not clear, so a closed dialog left it behind. buildHref preserved formError, 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. MutationForm marked whichever field its call site named and threw away the details.field the refusal carried, so a bad PRIORITY put the invalid ring on the base url; it marks the field the server named as well. readNumber answered undefined both for a field that was absent and for one holding "high", and every caller had a ?? 100 behind it — present-but-unparseable is now a 400 that names the field, which is what makes the ring land on the right one. SyncedSearchInput read its sibling draft with a document-wide querySelector while the select beside it used form scope; both are form-scoped now. Displays that were saying the wrong thing. The expiry countdown seeded now with 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. consoleStatusLine printed "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. updateApiKeyWithSettings skipped 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 and limits_enabled decides enforcement alone (the drawer's Disable, which keeps rules without touching them, is a different call and unchanged). defaultApiKeyLimitFormValues had no caller left. Hygiene. api/_standalone-theme.ts is a hand-written copy of the console's tokens with no guard: a new parity test holds that every var(--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 bare rgba(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 a chromium.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 (with noConsole off, as tests are) and tsconfig.scripts.json runs in verify, which immediately found two real errors — an unmatched capture group used as an index in env-loader.ts, and ConsoleProcess.child declared ChildProcessWithoutNullStreams when the process is spawned with stdin ignored. Two halves left undone, deliberately: a never-probed connection still counts as serving, because provider_health_summary stores 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: the formError rule and the numeric refusal as units, an e2e that types "high" into PRIORITY and checks the message and which field carries aria-invalid, a db-level case for keeping the ceilings while switching enforcement off, and the standalone-theme parity test. One stale assertion updated: platform-foundation pins the exact verify chain, which now carries the scripts typecheck. Gates: pnpm run verify EXIT=0 (lint clean, typecheck 13/13 + scripts, unit 543/543 across 33 files, build); pnpm run verify:features EXIT=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 into request_activity.route_reason — including the ones it filtered out and why — and the console read only .message; readConsoleActivityRouteCandidates now parses them, getConsoleActivityDetail resolves 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_at reached ProviderConnection and 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 with request_costs.cost_source; the four dimension breakdowns now carry estimatedCostRequests and the detail reads $3.75 (2 estimated). The design's own vocabulary, on the screens that use it: the Data quality panel calls the SegmentBar that 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 a reconciled row 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; formatCapabilities reads the modalities that were on the row and unread (tools · vision · reasoning, no stream — every routable model streams); the Route candidates table gained CTX; formatModelContextTokens went compact (200k, 1M) while keeping the invariant its tests exist for — two decimals leave 1,048,576 as 1.05M, distinct from 1M, which is the pair a capability contract refuses; Activity's TIME says which second a request started in; the drawer badge reads 200 · 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 says 104 failed · 38 fallback from one added indexed query rather than by pulling all of getConsoleUsageBreakouts onto 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 through matchMedia rather 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 / never for 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 in playground-streaming.e2e by seeding the activity row the stub gateway's request id points at; and console-shared-formatters.unit gained 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 in virtual-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 read filteredCandidates.length unconditionally, 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 verify EXIT=0 (lint clean, typecheck 13/13, unit 539/539 across 33 files, build); pnpm run verify:features EXIT=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: buildHref wrote ?grantIds=, readParam reads "" 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, which assertDefaultVirtualModelIsAllowed refuses for a state the screen no longer shows. New _ui/api-keys/grant-params names 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. replaceApiKeyLimitRulesWithClient deleted only the limit_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: updateProviderApiKeySettings and updateProviderOAuthConnectionSettings wrote label/priority straight 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_keys only constrains priority >= 0); both now normalize, and the OAuth normalizers raise a validation error instead of a bare Error, 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; one readPageSizeParam now bounds both. A budget window of the wrong period still paired with the rule: the query preferred a matching period_type but 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 (and formatRelative degraded 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/result lookup threw away an answer already in hand, and a stream cut mid-flight left the pane saying "streaming…" for the rest of the session. CopyButton called navigator.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-text holds 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.e2e and two db-level cases in api-key-management.e2e (one field cleared, then every field; a budget window only for its own period); api-key-limit-clearing.e2e drives the Limits drawer and reads the rules back once the save has answered; console-grant-params.unit and console-copy-text.unit; new sections in api-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=100000 renders 100), playground-streaming.e2e (a 401's toast, a cut stream) and console-api-hygiene.unit (the error id). Two stale assertions repaired: console-interactions waited for defaultGrant === "", the encoding the grants fix replaced, and api-key-dialog-parity compared 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 verify EXIT=0 (lint clean, typecheck 13/13, unit 534/534 across 33 files, build); pnpm run verify:features EXIT=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: resolveVirtualModelCapabilityContract returned 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 optional label — Console passes the option label, Gateway providerKey - modelId), with details keeping the first mismatch's keys and gaining a mismatches array. API key editor rebuilt to the design: full-width NAME; a grants browser with its own search, Show: all / granted only / not granted and 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 same block / warn_only select 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. EditorNav moved 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 shared api/_standalone-theme carrying 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 keyed 06:00 and 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 /activity and /limits); console-usage-axis.unit (1–4 buckets, 24 buckets, days, empty); the presentation contract now renders the one-time page and checks every data-copy target exists, the theme rule and bootstrap are present, and the hand-off is data-handoff rather than a URL; console-visual-design.e2e pins the module row's ink/dim colours against the accent; console-providers-ia-and-forms.e2e covers 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 code became Open 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 for text/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 and overview-list-caps.e2e seeds 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: with stream=true it read the body with response.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 showed 400 error over No 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 verify EXIT=0 (lint clean, typecheck 13/13, unit 524/524 across 33 files, build); pnpm run verify:features EXIT=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.css goes 4178 → 215 lines and holds the token table as CSS variables — :root light, [data-theme="dark"], plus a duplicated prefers-color-scheme: dark block 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, _lib are 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 a 1M rounding 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/db gains console-runtime-status (footer: server version + active worker jobs) and the three queries the design listed as missing — trend-bucket failure counts, getConsolePreviousWindowKpis for the Overview period-over-period deltas, listConsoleVirtualModelCandidateTraffic for per-candidate share — plus getConsoleUsageBreakouts (sequential awaits: one pooled client serialises and pg warns on concurrent queries); console-api-keys +lastUsedAt/setApiKeyLimitsEnabled, console-api-key-limits +listConsoleCurrentBudgetPeriods and enforcementPolicy, console-providers +listConsoleProviderModelRefreshStatuses, console-route-policies +availability/pageSize and listProviderModelOptionsByIds, console-virtual-models +listConsoleApiKeyVirtualModelGrants. console-format shrank 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 were divs with role=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 verify EXIT=0 (lint clean, typecheck 13/13, unit 500/500 across 33 files, build); pnpm run verify:features EXIT=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 onto dev after 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's grok-*-multi-agent* variants are Responses-only, same proxy base), quotaSource flipped not_supported{ supported: true }. Responses seam (gateway-responses.ts): filter extended codex-only→codex-or-grok via the extracted predicate responsesSupportsSubscriptionProvider (+guard that claude_code/minimax_coding stay rejected), callProvider +grok dispatch; createGrokSubscriptionAdapter gained a response method (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 (grokQuotaProbeConfig centralizes billing path, credits query, three headers Bearer + X-XAI-Token-Auth + Accept, 15s timeout) — GET /billing?format=credits then GET /billing; exported parseGrokCreditsQuota (period creditUsagePercent 0-100→0-1 clamp, missing field→0 for a new period; window from currentPeriod.type else weekly) and parseGrokMonthlyQuota (used.val/monthlyLimit.val USD 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): expectedRegistry grok +responses + supported, provider-quota supported[] 12→13 (+grok) / unsupported holds at 19 (grok was Feature A's not_supported bump then removed here), quotaProbes.grok registered; registry (35) / remoteKeys (32) / selector / price / long-tail / coverage unchanged. Tests: batch8-grok.unit.case.ts describe "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.md Subscription list +Grok + Batch 8 paragraph/bullet (dual faces, popup OAuth at auth.x.ai, /billing dual-window quota, 403 gate closed-loop → switch to the xai API 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 verify EXIT=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:features EXIT=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 OpenAI chat_completions face. Registry (packages/config/src/provider-registry.ts): grok — subscription, base https://cli-chat-proxy.grok.com/v1 (official inference proxy, not api.x.ai), chat_completions face, modelListStyle/connectivityProbeStyle grok, subscriptionAdapter grok, metadataKey xai, popup authorization-code OAuth against auth.x.ai (authorize/token/revoke, clientId b1a00492…, clientIdEnvVar GROK_OAUTH_CLIENT_ID, redirect 127.0.0.1:56121/callback, form encoding). Types +grok ×3 (subscriptionAdapter/modelListStyle/connectivityProbeStyle). Headers (subscription.ts): buildGrokSubscriptionHeaders + single grokClientVersion constant feeding both the grok-shell User-Agent and x-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.ts filter → adapter allowlist via extracted chatCompletionsSupportsSubscriptionProvider (+guard: claude_code/openai_codex/minimax_coding stay rejected), callProvider dispatches grok to createGrokSubscriptionAdapter; grok streaming dialect overrides only buildHeaders. model-list.ts/connectivity.ts grok branches reuse the header builder; console-provider-templates.ts SubscriptionProviderTemplateId +grok. Pins: registry 34→35, subscriptionProviderKeys 3→4, listProviderTemplateEntries 32→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 from dev). Tests: batch8-grok.unit.case.ts describe "Feature A" (6 it) + shell; batch8-grok-egress.e2e.case.ts + shell; Console E2E grok in the Subscription group + authorization-code dialog linking to auth.x.ai. .env.example/ARCHITECTURE.md untouched. Post-rebase gates covered by the Feature B wrap-up verification below (pnpm run verify EXIT=0; pnpm run verify:features 32/32).

  • 2026-07-24: Fireworks quota probe shipped (30th feature, fireworks-quota-probe) — two-hop control-plane probe: GET {origin}/v1/accounts resolves the API key's account slug, then GET {origin}/v1/accounts/{slug}/quotas?pageSize=200 yields the monthly-spend-usd row rendered as a monthly_budget WindowEntry (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 to unauthorized); (2) usage assumed 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. Registry quotaSource flip to { supported: true }; supported probes 11→12, unsupported 20→19. Implementation: packages/provider/src/quota-probe.ts (fetchProbeJson split + fireworksQuotaProbe/parseFireworksQuota), packages/config/src/provider-registry.ts, unit/e2e pins. Docs: docs/PRODUCT.md Batch 4 intro + Fireworks bullet. pnpm run verify EXIT=0 (unit 508/508, build 13/13); feature verification EXIT=0 (unit 51/51, e2e 14/14). pnpm run verify:features EXIT=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-presets in 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_completions api_key provider on the documented mantle OpenAI-compatible face: default base https://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): KnownProviderKey 33→34 (bedrock after anthropic), providerRegistry/knownProviderKeys +1, providerTemplateSelectorOrder +1 (bedrock after deepseek); console-provider-templates.ts OpenAICompatibleProviderTemplateId +1 and ids array +1 appended; price-source.ts alias amazon-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.md API Key list +AWS Bedrock + Batch 7 bullet. pnpm run verify EXIT=0 (unit 503/503, build 13/13); feature verification EXIT=0 (unit 113/113, e2e 15/15). pnpm run verify:features EXIT=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, base https://opencode.ai/zen/go/v1) has two routable faces from one base (command_code precedent) — OpenAI Chat Completions with a Bearer key, plus Anthropic Messages where the upstream authenticates with a bare x-api-key (hardcoded by the anthropic adapter; creation.auth stays Bearer for the chat/Console face), with default Bearer connectivity/model discovery; xiaomi_token_plan (Xiaomi MiMo Token Plan, default sgp base https://token-plan-sgp.xiaomimimo.com/v1, base user-editable with cn/ams documented) and mistral_vibe (Mistral Vibe, base https://api.mistral.ai/v1 shared with the standard mistral as a distinct key) are chat_completions-only with Bearer. Quota: opencode_go + xiaomi_token_plan not_supported, mistral_vibe requires_separate_credential (same judgment as mistral); no probe, no priceSyncSupported, no metadataKey. Wiring (packages/config/src/provider-registry.ts): KnownProviderKey 30→33 (mistral_vibe after mistral, opencode_go after openai_codex, xiaomi_token_plan after xiaomi), providerRegistry/knownProviderKeys +3, providerTemplateSelectorOrder 28→31 (opencode_go, xiaomi_token_plan, mistral_vibe inserted after ollama_cloud); console-provider-templates.ts OpenAICompatibleProviderTemplateId +3 (alpha) and openAICompatibleProviderTemplateIds +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, listProviderTemplateEntries 28→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.ts describe "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) with provider-coverage.unit.case.ts scenario-list sync; Console E2E "Add Provider API Keys group carries the Batch 5 token-plan templates" (opencode_go chips Chat Completions+Messages, xiaomi_token_plan + mistral_vibe single chip, base prefills, no overflow 1280/390). Docs: docs/PRODUCT.md API 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.example untouched; docs/PROVIDER_QUOTA.md absent from repo (not created). pnpm run verify EXIT=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:features EXIT=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: true added to groq/cerebras/fireworks/mistral/nvidia/xiaomi + cline_pass (its catalog section carries the channel's own resale prices); ollama_cloud stays off (subscription-billed, no per-token cost). packages/provider/src/price-source.ts: providerKeyAliases +4 — cline-pass→cline_pass and fireworks-ai→fireworks are price-path-required (models.dev section names), zai-org→zai and minimaxai→minimax are metadata-prefix-only (no same-name models.dev section, zero price impact). W1 prefix-vendor resolution: resolveProviderModelMetadataEntry gains findPrefixVendorRegistryEntry between the provider-scoped lookup and the tiered cross-catalog sweep — a vendor/model id resolves straight from resolveRegistryCatalogKey(prefix)'s catalog via the existing crossCatalogIndex, 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 returned null (the tier-1 degradation, nvidia now allowlisted); adding the function turned it green. New tests: provider-model-metadata-fallback.unit.case.ts describe "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 via enrichListedProviderModels; 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 to novita; the Batch 3 cline_pass entry test and the Batch 4 field-for-field test updated to assert the new priceSyncSupported flag (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.md API 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.md absent from repo (not created); .env.example/docs/ARCHITECTURE.md untouched. pnpm run verify EXIT=0 (491 unit + build); Feature B verification EXIT=0 (54 unit + 1 e2e); pnpm run verify:features 27/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 only quotaSource — six not_supported, mistral requires_separate_credential (its Admin usage API needs a separate enterprise credential); no probe, and (this feature) no priceSyncSupported. ollama_cloud is a remote api_key provider kept independent from the Local ollama daemon by a dedicated assertion. Wiring (packages/config/src/provider-registry.ts): KnownProviderKey 23→30, providerRegistry/knownProviderKeys +7 (alphabetical), providerTemplateSelectorOrder 21→28 (mistral, groq, cerebras, fireworks, nvidia, xiaomi, ollama_cloud inserted after nous); console-provider-templates.ts OpenAICompatibleProviderTemplateId +7 and openAICompatibleProviderTemplateIds +7 (selector order). Egress reuses the OpenAI adapter, no new code. Pins (A): registry 23→30, listProviderTemplateEntries 21→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 verify EXIT=0; Feature A verification EXIT=0 (113 unit + 13 e2e); pnpm run verify:features 26/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 base https://api.cline.bot/api/v1, byteplus_coding base https://ark.ap-southeast.bytepluses.com/api/coding/v3; behavior quotaSource {reason:not_supported,supported:false} only (no probe, no priceSyncSupported, no metadataKey). byteplus_coding carries the _coding suffix but is paste-key (distinct from the subscription minimax_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), providerTemplateSelectorOrder inserted cline_pass, byteplus_coding between command_code and nous → terminal …glm_coding, command_code, cline_pass, byteplus_coding, nous, ollama…; console-provider-templates.ts OpenAICompatibleProviderTemplateId +2 and openAICompatibleProviderTemplateIds appended cline_pass, byteplus_coding (long-tail/smoke order). Egress reuses the OpenAI adapter, no new code. Terminal pins (B): registry 21→23, listProviderTemplateEntries 19→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.ts describe 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.md API 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.md untouched. pnpm run verify EXIT=0 (unit + build); Feature B verification EXIT=0 (118 unit + 12 e2e); pnpm run verify:features 25/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 (base https://api.commandcode.ai/provider/v1) is the first api_key provider with two routable faces from one base: OpenAI Chat Completions with a Bearer key, plus Anthropic Messages where the upstream authenticates with a bare x-api-key + anthropic-version (hardcoded by the generic anthropic adapter — creation.auth stays Bearer for the Console/chat face). Model discovery/connectivity are the default Bearer Chat Completions path. nous (base https://inference-api.nousresearch.com/v1) is chat_completions-only with Bearer. Both quotaSource {reason:not_supported,supported:false}, no probe, no priceSyncSupported, no metadataKey. 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, not authorization) is exercised end-to-end by a provider-coverage-smoke.ts scenario (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.ts describe 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 chips Chat Completions+Messages, nous single chip, base prefill, no overflow 1280/390). pnpm run verify EXIT=0; Feature A verification EXIT=0 (113 unit + 6 e2e); pnpm run verify:features 24/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 from normalizeProviderKey to a new resolveRegistryCatalogKey that 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 keep normalizeProviderKey, so the price gate is unchanged (guarded by a test asserting the cline_pass price is dropped while anthropic survives). (2) Tiered fallback: new resolveProviderModelMetadataEntry runs the provider-scoped lookup first (unchanged findProviderModelRegistryEntry), 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 staying null on intra-layer conflict; candidates are the raw id, a vendor-prefix-stripped id, and the display name, indexed once per entries array via a WeakMap. enrichListedProviderModels routes through it with no signature change (release-behavior-smoke contract stays green). (3) Cache: fetchProviderModelRegistryEntries/fetchProviderModelPrices now fetch through a per-URL cachedFetchJson (env WORKER_MODEL_CATALOG_CACHE_TTL_MS, default 1800000ms, 0 disables, non-negative-integer validated) with single-flight and stale-on-error; module cache exposes resetProviderModelCatalogCacheForTests, wired into the new case and provider-model-capability-sync.unit.case.ts beforeEach. 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 — readModelCatalogCacheTtlMs is exported from price-source and called in createCoreMaintenanceTasks next to readRetentionCleanupSettings, so a bad value fails the worker fast instead of being swallowed by Promise.allSettled at refresh time. Cross-catalog hits are stamped for observability: enrichListedProviderModels writes resolvedVia: "cross-catalog" + resolvedFromCatalog: <catalog key> into capability_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: new tests/features/provider-model-metadata-fallback.unit.case.ts (10 it) attached to provider-connection-health.unit.test.ts; new tests/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.md updated. The stale-on-error branch emits logger.warn({ err, url }, "model catalog source fetch failed; serving stale payload") via a module createLogger("provider") from @llmingress/logging (added to packages/provider deps, mirroring packages/db); the cold-cache rethrow path stays unlogged since the caller surfaces that error. pnpm run verify EXIT=0 (471 unit + build); pnpm run verify:features 23/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.ts gains an anthropic-compatible category alongside the OpenAI-compatible one — type AnthropicCompatibleProviderTemplateId ("kimi_coding") merged into ProviderTemplateId, type AnthropicCompatibleProviderTemplate, whitelist anthropicCompatibleProviderTemplateIds, 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 in providerTemplateSelectorOrder. Registry (packages/config/src/provider-registry.ts §2.1): kimi_coding base https://api.kimi.com/coding/v1 (/v1 in the base), endpoints:{ messages: messagesEndpoint } (shared const path "messages", never v1/messages), connectivityProbeStyle/modelListStyle "anthropic", quotaSource {supported:true}, metadataKey "moonshot", creation.auth { header:"x-api-key", scheme:"" } (new anthropicTemplateAuth const); no priceSyncSupported; subscriptionProviderKeys untouched. Messages egress reuses the existing anthropic adapter — buildAnthropicMessagesUrl(base)https://api.kimi.com/coding/v1/messages, buildAnthropicProviderHeaders forces x-api-key. Quota probe (packages/provider/src/quota-probe.ts §7): quotaProbes.kimi_codingGET .../coding/v1/usages with Authorization: Bearer + Accept: application/json (a different endpoint and auth than the messages egress); new parseKimiQuota — first detail-bearing limits[]five_hour, usageweekly_limit, utilization=(limit-remaining)/limit (0-1, not ×100), tolerant resetTime → ISO resetsAt, extra limits[] 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 the provider-coverage.e2e.case.ts scenario added to tests/support/provider-coverage-smoke.ts (asserts /kimi/coding/v1/messages path and x-api-key, not Authorization); 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 verify EXIT=0 (424 unit + build); pnpm run verify:features 20/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, providerRegistry entries +2, knownProviderKeys +2, providerTemplateSelectorOrder +2 (qwen_token_plan after qwen, glm_coding after zai); subscriptionProviderKeys and directCreateOrder untouched. glm_coding base https://api.z.ai/api/coding/paas/v4, metadataKey zai, quotaSource {supported:true}; qwen_token_plan base https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1, chat_completions ONLY (no responses — §0 non-target), metadataKey qwen, quotaSource {reason:not_supported,supported:false}. Both added to openAICompatibleProviderTemplateIds so 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: zaiQuotaProbe extracted into a named const referenced by both zai and glm_coding, so quotaProbes.glm_coding === quotaProbes.zai and 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 sets priceSyncSupported). 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 in console-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 verify EXIT=0 (417 unit + build, coverage above thresholds); pnpm run verify:features 19/19 zero regression. Deferred to the Batch-1 wrap-up (span Feature B): §11 doc edits to docs/PRODUCT.md, the local provider quota reference doc, docs/ARCHITECTURE.md. Feature B (kimi_coding + W1 anthropic-compatible template) remains pending for 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}/models with curl. 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).
  • 2026-07-21: Gateway circuit breaker boundary/negative coverage backfill (PR #43). One production change: gatewayBreakerWindowMs now floors GATEWAY_BREAKER_WINDOW_MS at 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; executeHalfOpen queues excess calls on the trial's decision rather than throwing) — corrected to the true behavior, no production defect. Gates: pnpm run verify EXIT=0 (403 unit + build), verify:features 18/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_open attempt 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) and verify: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_enabled switch gained its Console UI: a Pause/Resume control inside each connection's quota cell, backed by setProviderApiKeyQuotaProbeEnabled / setProviderOAuthQuotaProbeEnabled (plain transactions; re-enabling nudges next_refresh_at = now() so the 5-minute scan probes promptly) and quota-probe-enable|disable actions on both credential routes; the read model exposes the raw quotaProbeEnabled alongside the composite probingEnabled. Debugging note: the page E2E hung 4 minutes because the toggle form was rendered as a sibling of span.quota-cell while the test locator scoped clicks inside it — the Playwright trace's single pending Frame.click pinned it; the form now renders as ProviderQuotaCell children, 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: verify 391 unit EXIT=0, verify:features 17/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 incremental 0002_provider_quota.sql and the 0001 baseline 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/usage reports utilization as 0-100 percent, unlike the 0-1 fraction in the anthropic-ratelimit-unified-* headers — a live account rendered 2400%/5300%; all eight parsers were re-audited, which also surfaced moonshot labeling .cn balances 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 (or quota_probe_enabled = false) renders "Probing paused" instead of ever-aging stored numbers — the read model now exposes probingEnabled across 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 single update ... where that 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: verify 389 unit EXIT=0, verify:features 17/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_supported cell 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) and verify: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.ts registration. TDD red-to-green: 10 E2E (api_key + OAuth happy paths, not_supported/requires_separate_credential proven not to call upstream via a throwing fetch stub, 403 → unauthorized with a 1h backoff, malformed base URL → probe_failed row, 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) and verify:features (16/16) passed. One collateral regression was caught and repaired: the new third core maintenance task broke worker-maintenance.e2e.case.ts's executedTasks === 2 pin.

  • 2026-07-19: Provider metadata registry (single source of truth) — new packages/config/src/provider-registry.ts holds 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-registry subpath). 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) and verify: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_idapi_key_id, integration_platform dropped, key_prefix/key_hash NOT 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 verify and verify:features passed.

  • 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 verify and verify:features (13/13, zero regression) passed.

  • 2026-07-18: Route policy randomload_balance rename + 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, and verify: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, and verify: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), and verify: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, and verify: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 verify and verify:features (9/9) passed.

  • 2026-07-16: Route-policy capability mismatch clarity — informative error values + precise context display; pnpm run verify and verify:features (9/9) passed.

Latest changes

  • 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 carries request_logging_modedefault (unchanged) or full. Migration 0005_api_key_request_logging.sql adds that text column with a two-value CHECK and request_activity.payload jsonb; both are metadata-only DDL, and the three-way migration pin (SQL file, shippedSqlMigrations checksum, platform-foundation manifest) 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 selects payload, so nothing detoasts on a page of 20 rows. New packages/gateway-runtime/src/gateway-payload-capture.ts holds the whole capture policy: captureGatewayPayloadValue keeps 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 a truncated flag either way, and returning an empty capture rather than throwing on a value JSON.stringify cannot hold. createGatewayBoundedPayloadAccumulator collects 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-auth selects the column, narrows it with the domain guard and falls back to default for a value the CHECK cannot produce; request-recording takes clientRequestBody + requestLoggingMode on both wrappers, captures both sides on the JSON and stream-prefailure paths, and fans the accumulator into the existing collectChunk so gateway-stream-pipeline is untouched — a stream that fails or is abandoned mid-flight still records what it sent, which is what full means. apps/gateway gained no dependency: it reads the mode type re-exported from gateway-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; and preview-console.ts now 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 — and null for a key that captured nothing) and api-key-request-logging.e2e.case.ts (one gateway process, four seeded routes: full JSON, full stream against the fake provider's SSE mode, full against a failing provider, and a default key 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 verify EXIT=0 (lint clean, typecheck 13/13, unit 636/636 across 35 files, build 13/13), pnpm run verify:features EXIT=0 — all 36 passing features re-verified (unit batch 7.0s, e2e batch 333.5s), zero regression. Ops note for the PR: a full key 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_mismatch recorded 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-headers on the same branch): a streaming request whose every attempt failed before the first byte recorded no route at all — executeGatewayStreamingRequest built its GatewayRequestActivityRoute only on the success path, so request_activity rows for failed streams carried null route_policy_strategy_snapshot, null route_reason (no requestedTag/matchedTag for a tag route) and zero fallback_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 client x-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-headers revised 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. playgroundHeaderOptions holds the nine names left after authorization and content-type are dropped from the eleven-name allowlist copy, with x-llmingress-route-tag lifted 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, which fetch throws on naming no field — and, defensively, a name the picker cannot produce), and duplicate (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 from helpers.ts. Unchanged on purpose: apps/gateway/ and packages/gateway-runtime/ (not one byte), the playgroundSendableHeaders copy and the unit test that reads apps/gateway/src/cors.ts and compares its access-control-allow-headers literal, the spread order — ...builtHeaders.headers ahead of the form's authorization/content-type/generated playground_<uuid> x-request-id, still pinned by index in a unit test — the key-only /v1/models probe, the four-state route tag trace row, and the rule that this input never reaches the URL (headersText became headerRows: PlaygroundHeaderRow[], still React state only). One thing the plan did not foresee: the rows sit inside the HEADERS Field, whose <label> lends its text to every labelable descendant as an accessible name, so + Add header could not be found by its visible text until each control was given an explicit aria-label — the row controls now carry Header 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 answers OPTIONS with 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-id stays 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 only authorization and content-type, so the tag route strategy shipped the day before could not be tried from the Console at all. A HEADERS field now takes one header per line; parsePlaygroundHeaders (in the Playground's own helpers.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 block send() 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"), and not_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 under apps/gateway/ or packages/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 in apps/gateway/src/cors.ts order — and the copy is held in place by a source-reading unit test that extracts the access-control-allow-headers literal from that file and compares it to playgroundSendableHeaders.join(", "), the same guard shape route-strategy-registry and console-route-policy-warnings already use. Two safeguards keep a typed line from taking over the request: the parser refuses the reserved names, and the fetch spreads ...parsedHeaders.headers ahead of the form's authorization/content-type/generated x-request-id so the form's values win regardless; every send creates a fresh playground_<uuid> and a unit test pins that order by index. headersText is React state only and never enters the URL (/playground remembers no query in nav-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 a route tag row with four states from describePlaygroundRouteTag: the matched tag, <tag> → default (no match), no tag → default, and . Its data comes from readConsoleActivityRouteTag (new in packages/db/src/console-activity.ts, shaped after readConsoleActivityRouteCandidates), which returns null when route_reason is not a record or names none of matchedTag/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: formatConsoleActivityRouteReason already 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) and tests/e2e/playground-request-headers.e2e.case.ts, which boots the real Console against a stub gateway that answers OPTIONS with the same static allowlist — a real browser will not send a custom header until a preflight allows it — and asserts the stub actually received x-llmingress-route-tag: fast, two consecutive sends carried distinct generated x-request-id values, a fixed typed id is refused, the trace row for a seeded hit and a seeded miss, and that a x-custom line 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 verify EXIT=0 (lint clean, typecheck 13/13, unit 617/617 across 34 files, build 13/13); pnpm run verify:features EXIT=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 fourth RoutePolicyStrategy, tag, routes one request to one named candidate instead of ordering the whole candidate set. Migration 0004_route_policy_candidate_tags.sql adds route_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 carries default) stay in the application layer because the candidate write is one delete-and-rewrite transaction holding the route_policies row lock and a uniqueness rule spanning rows of a text[] is not an ordinary constraint. The three-way migration pin (SQL file, shippedSqlMigrations checksum, platform-foundation manifest assertion) stays aligned. Domain (packages/domain/src/index.ts): all tag behavior is a registry handler, so the route-strategy-registry guard that the domain source contains no strategy === " still holds — RouteStrategyHandler gained capabilityContractScope (all_candidates for the three ordering strategies, selected_candidate for tag) and an optional annotateReason({chain, context}) whose result is spread into routeReason and handed to decisionMessage; 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 answers provider_unavailable without trying a non-default candidate. New exports ROUTE_TAG_DEFAULT, normalizeRouteTag (trim + lowercase), isValidRouteTag (/^[a-z0-9][a-z0-9._-]{0,63}$/, checked after normalization) and routeStrategyCapabilityContractScope. Gateway: header x-llmingress-route-tag read by readGatewayRequestedRouteTag (first value, normalized, empty → undefined; an unusable tag is not a refusal — it matches nothing and the default candidate answers), added to providerRequestHeaderDenylist so it never reaches an upstream and to the CORS allow-headers; threaded from apps/gateway/src/main.ts through the three protocol executors into both selectRouteAttempts call sites, and echoed on GatewayRequestMetadata.requestedTag via withGatewayRequestedRouteTag. 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_candidates keeps the shared contract, selected_candidate builds the contract from chain[0] alone, so a tagged candidate that cannot serve the request answers 4xx virtual_model_capability_mismatch even 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 carries candidateTags (one comma-separated list per candidate) normalized into string[][] with route_policy_tag_invalid / _duplicate / _missing / _default_required, cleared for non-tag strategies; assertRoutePolicyCandidateCapabilityContract runs only for all_candidates scope; candidate insert/select and the list query carry tags; and a new buildTagRouteCoverageWarnings compares 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 in routeWarnings. UI: the strategy note names the header and the fallback rule (TS forces both Record<RoutePolicyStrategy, string> maps), the editor swaps its two candidate headings for the tag semantics and emits one candidateTags field per selected candidate (a hidden empty one for other strategies) in the same order as the providerModelIds hidden inputs — the API route reads it with a new readAlignedTextValues that 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, renders policy.routeWarnings at all, which is what makes the soft coverage warnings (and the pre-existing price/availability ones) visible. Deviation from the plan: it specified readTextValues for candidateTags; 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 join PRESERVED_EDITOR_FIELDS because repeated names read back as a RadioNodeList, so reordering, adding or removing a candidate and switching strategy drop unsaved tag text — the same as NAME and DESCRIPTION under plain Link navigation. Tests: tag cases in route-strategy-registry.unit.case.ts (three decision messages, normalization, hit===default, defensive no-default, contract scope per strategy, tag validity), a new tag-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 in virtual-model-capability-contract.unit.case.ts, header hygiene + CORS in gateway-request-hygiene.unit.case.ts, render-point assertions in console-route-policy-warnings.unit.case.ts, and E2E coverage behind tests/e2e/tag-routing.e2e.spec.ts — gateway (tag hits its candidate and the header does not egress; no tag → default; unknown tag → 200 with requestedTag/tagFallback in request_activity.route_reason; a failing tagged candidate falls to the default and stops there with a third candidate untouched and 2 fallback_events; both failing = exactly 2 attempts; a filtered no-default snapshot returns provider_unavailable with 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.ts gained strategy/tags and a seedGatewayRouteCandidate helper. Gates: the feature's own verification EXIT=0 (unit 106/106 across 4 files, e2e 14/14 in 58.4s); pnpm run verify EXIT=0 (lint clean, typecheck 13/13, unit 602/602 across 33 files, build 13/13); pnpm run verify:features EXIT=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 endpoint GET {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 "mirror parseMinimaxQuota" note is that the coding_plan payload nests the windows in model_remains[] under model_name === "general" (video skipped) and gates the weekly window on current_weekly_status === 1 (status 3 = no weekly cap), so parseMinimaxCodingPlanQuota is an independent parser, not a token_plan reuse. Egress: buildMiniMaxSubscriptionHeaders (Bearer + anthropic-version, strips x-api-key, no stainless/beta/UA) is shared by createMiniMaxProviderAdapter (adapters/subscription.ts; body = buildAnthropicMessagesPayload + system: withClaudeCodeSystemPrompt; URL joinUrl(base, "messages") — no appendV1Path) and dialects.minimax_coding (default joinUrl build, transformBody injects the same identity block). gateway-messages dispatches the minimax_anthropic adapter (adapter selection + planCandidates.supported filter both updated) and reads providerApiKey.baseUrl ?? candidate.baseUrl; the per-token resource_url from the OAuth blob rides on the new FallbackProviderApiKey.baseUrl (subscription branch pushes token.resourceUrl, candidate assembly takes primaryKey.baseUrl ?? credential.baseUrl, and streaming's buildStreamingAttemptCandidate lets the base follow the rotated key). Quota: quotaProbes.minimax_coding derives the URL from the base origin (like zai) + Bearer with the independent parser, and the registry quotaSource flips to { supported: true } in the same commit (registry snapshot + provider-quota supported/unsupported/transport/remoteKeys 16 assertions updated). Refresh: no new code — a unit asserts isSubscriptionProviderKey("minimax_coding") (the gateway refresh-loop gate) and that refreshProviderOAuthToken("minimax_coding") round-trips expired_inexpiresAt and resource_urlresourceUrl through the shared path. Tests: dialect + adapter cases in provider-dialect.unit.case.ts (provider-management suite), quota parse/transport + refresh cases in provider-quota.unit.case.ts, and a new tests/e2e/gateway-minimax-egress.e2e.case.ts that seeds a subscription provider + provider_oauth and asserts — non-streaming AND streaming — Bearer, the injected identity system block, no x-api-key/stainless/anthropic-beta, URL .../anthropic/v1/messages, and per-token resource_url outranking the registry base. pnpm run verify EXIT=0; pnpm run verify:features 22/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) parseMinimaxCodingPlanQuota throws on a non-zero base_resp.status_code so the shared parsed() catch returns probe_failed with the status_msg — a stale token no longer renders as empty quota (probe-level assertion in provider-quota.unit.case.ts); (2) attachGatewayProviderCredentials sets candidate.baseUrl to credential.baseUrl only (the provider-base anchor), so a bare rotated OAuth connection resolves via the call sites' providerApiKey.baseUrl ?? candidate.baseUrl to the provider base instead of the primary connection's resource_url; non-oauth/claude_code/codex keys carry no baseUrl and degrade to the original behavior (two-connection base-anchor assertion in gateway-request-hygiene.unit.case.ts); (3) refreshProviderOAuthTokenWithLock persists refreshed.resourceUrl ?? current.resourceUrl, so a refresh response without resource_url keeps the prior per-token base (refresh-preservation assertion checking the returned blob and the read-back ciphertext). Post-fix pnpm run verify EXIT=0 (445 unit + build) and pnpm run verify:features 22/22 zero regression. Pre-merge review round 2 (PR #45, 4 fixes, TDD red-first): (1) the local provider quota reference doc — the coding_plan/remains bullet 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 a minimax_coding row; (2) both soft-delete paths (deleteProviderOAuthConnection, deleteProvider cascade) now null the three device pending columns, symmetric with completeProviderOAuthConnection; (3) security — sanitizeProviderOAuthResourceUrl validates the upstream resource_url at 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 — rewriteDeviceVerificationUri throws on a non-https scheme so a javascript:/http: verification_uri fails the start flow instead of reaching a Console href. pnpm run verify EXIT=0 and pnpm run verify:features 22/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 — completeProviderOAuthConnection gained onlyIfPending (conditional WHERE ... 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) normalizePollIntervalSeconds clamps the normalized interval to [1, 60] seconds against extreme upstream values. pnpm run verify EXIT=0 and pnpm run verify:features 22/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). Migration 0003_provider_oauth_device.sql adds pending_user_code/pending_verification_uri/pending_interval_seconds + a flow_type discriminator (default authorization_code, CHECK in {authorization_code, device_code}) to provider_oauth; the three-way migration pin (SQL file, shippedSqlMigrations checksum, platform-foundation manifest assertion) stays aligned. ProviderOAuthConfig became a presence-discriminated union (authorization-code carries authorizeUrl/redirectUri; device-code carries deviceCodeUrl/defaultPollIntervalSeconds, mutually exclusive via ?: never) so existing OAuth entries' snapshots stay byte-identical; buildProviderOAuthAuthorizeUrl/exchangeProviderOAuthCode narrow via requireAuthorizationCodeOAuthConfig. New registry provider minimax_coding (subscription; base https://api.minimax.io/anthropic/v1; subscriptionAdapter: minimax_anthropic; quotaSource not-yet-supported — Feature B flips it with the coding_plan probe) plus carrier/order/type wiring and a providerUsesDeviceCodeOAuth helper. Engine: requestProviderOAuthUserCode posts the PKCE challenge and rewrites the verification host (www.minimax.ioplatform.minimax.io on /oauth-authorize); pollProviderOAuthUserCodeToken maps body status (error/non-2xx→error, non-success→pending, success→normalize); normalizeTokenBody gained an expires_in ?? expired_in fallback (poll + refresh) and captures resource_url into the token blob's new optional resourceUrl, round-tripped through both readProviderOAuthTokenBlob sites. Storage/service: device pending write + start-time clear-old-rows + pollProviderOAuthDeviceAuthorization (local expiry, one upstream poll, complete); completeProviderOAuthAuthorization rejects 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 verify EXIT=0 (lint+typecheck+436 unit+build); pnpm run verify:features 21/21.
  • The Gateway request path now carries an in-memory per-connection circuit breaker. packages/gateway-runtime/src/gateway-circuit-breaker.ts is 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, halfOpenSampling trials probe recovery, and onBreak/onReset/onHalfOpen log each transition. Every provider attempt in executeProviderFallbackAttempts is routed through the registry; a BrokenCircuitError is turned into a synthetic provider_circuit_open attempt 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 the provider_health_summary check, and those summary reads are now TTL-cached in memory keyed by databaseUrl (GATEWAY_HEALTH_SUMMARY_CACHE_TTL_MS, default 5s, 0 disables). 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_MS backoff 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 the gateway_credential_error enqueue 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.ts is the read model: withPooledPostgresClient, the same three-kind provider_connections CTE as console-provider-health.ts, a left join to provider_quota_summary, camelCase output with Date | null, ordering in SQL. A connection with no summary row yields entries: [], errorCode: null, observedAt: null, which is what makes "not yet queried" distinguishable from an error_code meaning "cannot be retrieved". Rendering splits across apps/console/src/app/_lib/provider-quota-format.ts (pure, unit-tested view builder) and a new Quota column in providers-client-section.tsx; providers-section.tsx only loads the data, because the connection table lives in the client component. Entries are discriminated by isWindowEntry/isBalanceEntry from @llmingress/domain/quota, never by a type tag, so one claude_code connection renders its windows and its overage balance. not_supported and requires_separate_credential render as a neutral pill with a plain-language reason and no zero value; only probe_failed/unauthorized get pill--warn, and nothing in the quota column ever uses pill--danger — a quota probe failure is not a connection-health failure. A live-but-tiny window renders <1% rather than rounding to 0%. Balance amounts stay the stored decimal string (never parsed to a number). Where several connections of one Provider report an identical currency + 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.ts gained a quota-specific connection enumeration (listDueProviderQuotaProbeConnections) whose predicate carries quota_probe_enabled = true, plus enqueueProviderQuotaProbeJob. 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.ts is the handler: a Provider whose quotaSource is unsupported — and any local Provider, which has no billing — writes entries = [] with its reason and never calls upstream; quota_probe_enabled = false returns { 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 reaches new URL() outside zai's internal try/catch) is recorded as probe_failed rather 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 a provider-quota-probe-enqueue core 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, dialect supportsPathSuffix, OAuth config reads, subscription URL path literals, adapter + connectivity + model-list URLs via defaultEndpointPathByProtocol/defaultModelListPath), packages/db (provider templates + selector groups, ProviderType, and listProviderRouteEndpointProtocols), the @llmingress/domain routeEndpointProtocols enum, packages/gateway-runtime streaming 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 onto formatRouteEndpointProtocolLabel. 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-registry subpath, which stays free of node:fs so the client dialog and @llmingress/domain can import it.
  • Renamed the product entity Agent → API Key across the whole monorepo (the gateway credential was only ever a key). DB: agentsapi_keys, agent_limitsapi_key_limits, agent_virtual_modelsapi_key_virtual_models, agent_idapi_key_id, request_activity.agent_key_prefix/agent_name_snapshotapi_key_prefix/api_key_name_snapshot; integration_platform column dropped; api_keys.key_prefix/key_hash are now NOT 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 codes missing/invalid/disabled_api_key and hash namespace llmingress:api-key:v1 updated (byte-identical in db + gateway). Integration-guide platform enum is now a UI-local IntegrationPlatform union 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 EmptyState primitive (_components/empty-state.tsx) replaces the ad-hoc <p>No…</p>/colspan empty cells across providers/models/overview/activity/limits; an accessible Spinner (_components/spinner.tsx, role=status, prefers-reduced-motion aware) marks Playground model-loading and send in-flight states; ConsoleMutationToast gained a success tone (.console-mutation-toast--success, ok tokens) and ConsoleMutationForm a successMessage prop 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 randomload_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 DB route_policies_strategy_check CHECK constraint was dropped — the allowed-value set now lives only in the code layer (routePolicyStrategies / isRoutePolicyStrategy). The former 0002_route_policy_load_balance.sql was folded into 0001_core_baseline.sql during 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_platform column 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 /connect detail + $schema, Claude Code ANTHROPIC_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-quality suites (9th feature renamed from release-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 selected URL param: /providers defaults 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_FILE to ENCRYPTION_KEY / ENCRYPTION_KEY_FILE. Provider secret crypto remains AES-256-GCM.
  • /v1/embeddings remains retired; embedding model metadata remains supported.
  • The API key created and detail dialogs render one shared ApiKeyDetailPanels in the same api-key-view-dialog shell; only the key field differs (plaintext with a hide toggle after creation, stored prefix with copy only in the detail view). createApiKeyWithSettings reads saved limits back in-transaction so the created dialog can render Budget/RPM/TPM/Token.

Blockers

  • None.