From 08dce7c83d487cada0e82edfcda8e75e1cab9a29 Mon Sep 17 00:00:00 2001 From: nexus Date: Mon, 3 Aug 2026 15:00:58 +0800 Subject: [PATCH] chore(repo): sync docs, tooling, and CI config from internal main (sync 12) Bring repository-level assets up to date. - Documentation: README, CLAUDE.md, CHANGELOG; the docs/ tree (developers, operators, users) including the DB migration mechanics architecture, which now documents that schema-extras.sql applies all-or-nothing and must fail loudly - Build & lint config: docker-compose.yml, .gitignore, .env.example - Developer tooling: scripts/, tools/, examples/, .githooks, .cursor rules, .claude/skills; tools/db-migrate gains a precondition script that clears superseded duplicate agent fingerprints before the extras apply - CI: .github/workflows The README CI badges now point at this repository's own Actions runs rather than a repository the public audience cannot see. Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/adapter-conformance-check/skill.md | 39 +- .claude/skills/add-provider-adapter/skill.md | 14 +- .claude/skills/sync-provider-pricing/skill.md | 77 +- .../sync-provider-pricing/sync_pricing.py | 146 +- .claude/skills/test-openai-responses/run.sh | 2 +- .cursor/rules/code-doc-lockstep.mdc | 1 + .env.example | 132 +- .githooks/pre-commit | 71 +- .github/workflows/buildbase.yml | 85 + .github/workflows/ci.yml | 68 +- .github/workflows/go-ci.yml | 43 +- .github/workflows/release.yml | 376 ++ .gitignore | 9 +- CHANGELOG.md | 1214 ++++ CLAUDE.md | 2 +- README.md | 27 +- docker-compose.yml | 15 +- docs/developers/architecture/README.md | 3 +- .../deployment/ami-appliance-architecture.md | 18 +- .../container-image-architecture.md | 445 ++ .../foundation/configuration-architecture.md | 26 +- .../endpoint-typology-architecture.md | 38 +- .../foundation/jobs-architecture.md | 16 +- ...s-response-markers.md => nexus-headers.md} | 142 +- .../service-bootstrap-config-architecture.md | 7 +- .../foundation/service-call-framework.md | 29 +- .../cross-cutting/foundation/thing-model.md | 7 + .../observability/alerting-architecture.md | 4 + .../audit-pipeline-architecture.md | 106 +- .../metrics-rollup-architecture.md | 12 + .../observability-architecture.md | 6 +- .../prometheus-naming-architecture.md | 2 + .../emergency-passthrough-architecture.md | 15 +- .../safety/error-taxonomy-architecture.md | 13 +- .../pii-redaction-policy-architecture.md | 41 + .../sse-streaming-compliance-architecture.md | 98 +- .../shared/shared-wirerewrite-architecture.md | 38 +- .../storage/cache-multi-tier-architecture.md | 41 +- .../db-migration-mechanics-architecture.md | 110 +- .../storage/spillstore-architecture.md | 112 +- .../nexus-operator-toolkit-architecture.md | 8 +- .../agent/performance-optimization-report.md | 223 + .../ai-gateway/aiguard-architecture.md | 2 +- .../cost-estimation-architecture.md | 47 +- .../services/ai-gateway/hook-architecture.md | 2 + .../services/ai-gateway/ingress-api.md | 84 +- .../ai-gateway/normalization-architecture.md | 26 +- .../performance-and-switches-explained.md | 238 +- .../performance-multimodal-report.md | 200 + .../ai-gateway/prompt-cache-architecture.md | 9 +- .../provider-adapter-architecture.md | 67 +- .../services/ai-gateway/provider-coverage.md | 12 +- .../ai-gateway/response-cache-architecture.md | 17 +- .../ai-gateway/routing-architecture.md | 21 +- .../ai-gateway/smart-routing-architecture.md | 35 +- ...raffic-capture-storage-normalize-design.md | 2 +- ...ance-proxy-connect-forward-architecture.md | 23 + .../performance-optimization-report.md | 200 + ...p-ai-providers-virtualkeys-architecture.md | 27 +- .../gateway-architecture-strategy.md | 222 + .../proposals/multimodal-full-gateway.md | 214 + .../proposals/realtime-voice-gateway.md | 345 ++ .../realtime-voice-protocol-facts.md | 138 + .../findings/00-attack-surface-map.md | 6 +- .../findings/01-modules/M3-vk-auth.md | 2 +- .../security/fix5-secret-architecture-plan.md | 2 +- docs/developers/workflow/conventions.md | 2 +- .../workflow/perf-optimization-playbook.md | 384 ++ docs/developers/workflow/timezone.md | 25 + docs/operators/ops/ami-build.md | 2 +- docs/operators/ops/container-deployment.md | 530 ++ docs/operators/ops/install-test-env.md | 3 - docs/operators/ops/monitoring.md | 106 + docs/operators/ops/runbooks/alerts.md | 159 + .../ops/runbooks/prod-deploy-data-changes.md | 118 + .../runbooks/vendor-bill-reconciliation.md | 173 + .../api/openapi/control-plane/_index.yaml | 10 - .../api/openapi/control-plane/ai-guard.yaml | 10 +- .../api/openapi/control-plane/cache.yaml | 60 - .../openapi/control-plane/diag-events.yaml | 29 + .../api/openapi/control-plane/hooks.yaml | 12 +- .../control-plane/interception-domains.yaml | 17 +- .../api/openapi/control-plane/models.yaml | 39 +- .../api/openapi/control-plane/providers.yaml | 51 +- .../openapi/control-plane/routing-rules.yaml | 77 +- .../control-plane/rule-pack-installs.yaml | 29 +- .../api/openapi/control-plane/rule-packs.yaml | 8 +- .../api/openapi/control-plane/settings.yaml | 12 +- .../api/openapi/control-plane/traffic.yaml | 340 +- .../openapi/control-plane/virtual-keys.yaml | 88 +- docs/users/api/openapi/e88-s6-videos.yaml | 455 ++ docs/users/api/openapi/e88-s7-realtime.yaml | 224 + docs/users/api/openapi/e89-s1-rerank.yaml | 327 + docs/users/api/openapi/e90-s1-guardrail.yaml | 270 + .../features/cp-ui/ai-gateway-cost-cache.md | 21 +- .../features/cp-ui/ai-gateway-routing.md | 14 +- docs/users/features/cp-ui/compliance-hooks.md | 2 + .../features/cp-ui/compliance-network.md | 14 +- .../features/cp-ui/infrastructure-ops.md | 4 + docs/users/features/cp-ui/overview.md | 29 +- docs/users/product/features.md | 7 + docs/users/product/overview.md | 2 +- scripts/.coverage-allowlist | 2 +- scripts/check-agent-cross-build.sh | 70 + scripts/check-comment-program-refs.mjs | 84 +- scripts/check-doc-lockstep.mjs | Bin 7620 -> 9329 bytes scripts/check-go-coverage.sh | 146 +- scripts/check-headers.sh | 122 + scripts/check-no-prod-todos.mjs | 50 +- scripts/check-no-redis-pubsub.mjs | 22 +- scripts/check-no-yaml-secrets.mjs | 34 +- scripts/check-prometheus-naming.sh | 139 + scripts/check-quirk-coverage.mjs | 445 ++ scripts/check-quirk-evidence.mjs | 197 + scripts/check-smoke-harness.py | 143 + scripts/check-terminology.sh | 31 +- scripts/check-timezone-correctness.sh | 8 +- scripts/check-workspace-replace.mjs | 59 +- scripts/dev-start.ps1 | 27 +- scripts/dev-start.sh | 47 +- scripts/doc-lockstep.config.mjs | 78 + scripts/quirk-coverage.config.mjs | 298 + scripts/release/build-images.sh | 101 + scripts/release/build-tarball.sh | 200 + scripts/release/smoke-compose.sh | 811 +++ scripts/release/verify-image.sh | 157 + tools/db-migrate/gen-model-catalog.mjs | 286 + ...duplicate_thing_physical_id_2026_08_03.sql | 170 + tools/db-migrate/model-catalog.json | 5354 +++++++++++++++++ tools/db-migrate/package-lock.json | 2224 +++++++ tools/db-migrate/package.json | 12 +- tools/db-migrate/schema-extras.sql | 41 +- tools/db-migrate/schema/cache.prisma | 10 +- tools/db-migrate/schema/compliance.prisma | 8 +- tools/db-migrate/schema/gateway.prisma | 49 + tools/db-migrate/schema/identity.prisma | 9 +- tools/db-migrate/schema/providers.prisma | 18 +- tools/db-migrate/schema/traffic.prisma | 67 +- tools/db-migrate/schema/vendor_bill.prisma | 68 + .../scripts/extract-reference-fixtures.ts | 29 +- .../db-migrate/seed/__tests__/_modelTable.ts | 109 + .../__tests__/demo-seed.integration.test.ts | 53 + .../seed/__tests__/loadFixture.test.ts | 436 +- .../seed/__tests__/model-catalog.test.ts | 106 + .../seed/__tests__/modelRefs.test.ts | 267 + .../seed/__tests__/reference-index.test.ts | 151 +- tools/db-migrate/seed/demo/index.ts | 165 +- tools/db-migrate/seed/fixtures/AlertRule.json | 60 + tools/db-migrate/seed/fixtures/Model.json | 1519 ++++- tools/db-migrate/seed/fixtures/Provider.json | 7 - .../db-migrate/seed/fixtures/RoutingRule.json | 42 +- .../seed/fixtures/ai_guard_config.json | 4 +- .../seed/fixtures/cache_global_config.json | 11 - .../seed/fixtures/demo/QuotaPolicy.json | 39 - .../seed/fixtures/demo/VirtualKey.json | 30 +- .../seed/fixtures/interception_domain.json | 63 - tools/db-migrate/seed/fixtures/rule.json | 8 +- .../seed/fixtures/system_metadata.json | 3 +- .../seed/fixtures/thing_config_template.json | 39 - tools/db-migrate/seed/reference-only.ts | 38 + tools/db-migrate/seed/reference/index.ts | 176 +- .../db-migrate/seed/reference/loadFixture.ts | 277 +- tools/db-migrate/seed/reference/modelRefs.ts | 140 + .../seed/rule-packs/nexus-pii-v1.1.0.yaml | 8 +- tools/db-migrate/seed/seed.ts | 42 +- 165 files changed, 23699 insertions(+), 1185 deletions(-) create mode 100644 .github/workflows/buildbase.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/developers/architecture/cross-cutting/deployment/container-image-architecture.md rename docs/developers/architecture/cross-cutting/foundation/{nexus-response-markers.md => nexus-headers.md} (50%) create mode 100644 docs/developers/architecture/services/agent/performance-optimization-report.md create mode 100644 docs/developers/architecture/services/ai-gateway/performance-multimodal-report.md create mode 100644 docs/developers/architecture/services/compliance-proxy/performance-optimization-report.md create mode 100644 docs/developers/proposals/gateway-architecture-strategy.md create mode 100644 docs/developers/proposals/multimodal-full-gateway.md create mode 100644 docs/developers/proposals/realtime-voice-gateway.md create mode 100644 docs/developers/proposals/realtime-voice-protocol-facts.md create mode 100644 docs/developers/workflow/perf-optimization-playbook.md create mode 100644 docs/operators/ops/container-deployment.md create mode 100644 docs/operators/ops/runbooks/vendor-bill-reconciliation.md create mode 100644 docs/users/api/openapi/e88-s6-videos.yaml create mode 100644 docs/users/api/openapi/e88-s7-realtime.yaml create mode 100644 docs/users/api/openapi/e89-s1-rerank.yaml create mode 100644 docs/users/api/openapi/e90-s1-guardrail.yaml create mode 100755 scripts/check-agent-cross-build.sh create mode 100755 scripts/check-headers.sh create mode 100755 scripts/check-prometheus-naming.sh create mode 100644 scripts/check-quirk-coverage.mjs create mode 100644 scripts/check-quirk-evidence.mjs create mode 100644 scripts/check-smoke-harness.py create mode 100644 scripts/quirk-coverage.config.mjs create mode 100755 scripts/release/build-images.sh create mode 100755 scripts/release/build-tarball.sh create mode 100755 scripts/release/smoke-compose.sh create mode 100755 scripts/release/verify-image.sh create mode 100644 tools/db-migrate/gen-model-catalog.mjs create mode 100644 tools/db-migrate/manual-scripts/clear_duplicate_thing_physical_id_2026_08_03.sql create mode 100644 tools/db-migrate/model-catalog.json create mode 100644 tools/db-migrate/package-lock.json create mode 100644 tools/db-migrate/schema/vendor_bill.prisma create mode 100644 tools/db-migrate/seed/__tests__/_modelTable.ts create mode 100644 tools/db-migrate/seed/__tests__/model-catalog.test.ts create mode 100644 tools/db-migrate/seed/__tests__/modelRefs.test.ts delete mode 100644 tools/db-migrate/seed/fixtures/cache_global_config.json create mode 100644 tools/db-migrate/seed/reference-only.ts create mode 100644 tools/db-migrate/seed/reference/modelRefs.ts diff --git a/.claude/skills/adapter-conformance-check/skill.md b/.claude/skills/adapter-conformance-check/skill.md index 9e7db627..450c1055 100644 --- a/.claude/skills/adapter-conformance-check/skill.md +++ b/.claude/skills/adapter-conformance-check/skill.md @@ -43,29 +43,30 @@ grep -nE 'claude-|gpt-[345]|kimi-|deepseek-|gemini-|o[1-9]|"thinking"' \ If anything matches, move the logic into the adapter's own package and wire it via `AdapterSpec.PassthroughRewrite` (or codec-internal methods if it's a codec concern). -## Step 2 — Verify every adapter wires PassthroughRewrite when applicable +## Step 2 — Verify per-model rules ride the right mechanism per adapter ```bash -# Adapters that ARE expected to have a PassthroughRewrite: -# - spec_openai (gpt-5.x / o-series reasoning rewrites) -# - spec_azure_openai (same — reuses spec_openai.ApplyReasoningRewrites) -# - spec_moonshot (kimi-k2.5 / k2.6 fixed-temp strip) -# -# Adapters that DO NOT need one (today): -# - All Tier-1 codecs (spec_anthropic, spec_gemini, spec_bedrock, -# spec_cohere, spec_replicate) — per-model quirks live in codec.go -# - OpenAI-compat siblings with no current per-model quirks -# (spec_deepseek, spec_glm, spec_minimax, spec_mistral, spec_xai, -# spec_groq, spec_perplexity, spec_together, spec_fireworks, -# spec_huggingface, spec_vertex) -grep -L "PassthroughRewrite" \ - packages/ai-gateway/internal/providers/specs/openai/spec.go \ - packages/ai-gateway/internal/providers/specs/azure/spec.go \ - packages/ai-gateway/internal/providers/specs/compat/moonshot/spec.go -# Expected: empty (the field IS wired in all three). +# The PassthroughRewrite mechanism is DELETED: every adapter's rules ride +# its codec (identity-codec Contract for the OpenAI family; codec-internal +# rules for the translation codecs). Any reappearance of the callback is +# drift: +grep -rn "PassthroughRewrite" \ + packages/ai-gateway/internal/providers/ --include="*.go" +# Expected: empty. + +# Rule-carrying OpenAI-family adapters construct their contract: +# - spec_openai / spec_azure_openai → rewrites.OpenAIContract() +# - spec_moonshot → moonshot.Contract() (fixed-temp field rules) +# - spec_deepseek → deepseek.Contract() (thinking-model structural rules) +# Tier-1 codecs (spec_anthropic, spec_gemini, spec_bedrock, spec_cohere, +# spec_replicate): per-model quirks live in codec.go. Remaining +# OpenAI-compat siblings with no probed quirk construct the identity +# codec with the zero Contract. ``` -When you add an adapter (or a new per-model quirk to an existing one), add `PassthroughRewrite: .ApplyRewrites` to `NewSpec`. +When you add a per-model quirk to an OpenAI-family adapter, add a +`FieldRule` (with its observed-400 evidence) to the sibling's contract in +its `rewrites` package — never a new callback. ## Step 3 — Scan hand-rolled error envelopes (Rule 6, §9.5) diff --git a/.claude/skills/add-provider-adapter/skill.md b/.claude/skills/add-provider-adapter/skill.md index 2d3a3a98..6e935e31 100644 --- a/.claude/skills/add-provider-adapter/skill.md +++ b/.claude/skills/add-provider-adapter/skill.md @@ -101,10 +101,16 @@ Add the adapter under `packages/shared/traffic/adapters/api//` and reg ### 6. Seed provider + initial models -Add a `Provider` row to `tools/db-migrate/seed/fixtures/Provider.json` and per-`Model` -entries to `tools/db-migrate/seed/fixtures/Model.json` (both are JSON arrays — append the -new objects). Alternatively, add them to the source DB and re-run -`tools/db-migrate/scripts/extract-reference-fixtures.ts` to regenerate both fixture files. +Add a `Provider` row to `tools/db-migrate/seed/fixtures/Provider.json` (hand-maintained). + +Models are NOT edited in `Model.json` directly — that file, plus the wizard's +`provider-templates/*.json` + `index.json`, are GENERATED from the single source of +truth `tools/db-migrate/model-catalog.json`. Add a provider block there (with a +`template` object for the wizard and, for each model to seed, a `seed` block carrying +its `id`/modalities/timestamps) and run `npm run gen:model-catalog`. `npm run +check:model-catalog` guards drift. See the generator header in +`tools/db-migrate/gen-model-catalog.mjs` for the catalog schema. + Also update `tools/db-migrate/seed/seed.ts` if the provider needs a cache-price backfill multiplier (`cachePriceBackfill` block). diff --git a/.claude/skills/sync-provider-pricing/skill.md b/.claude/skills/sync-provider-pricing/skill.md index 35b29fec..8ffb2975 100644 --- a/.claude/skills/sync-provider-pricing/skill.md +++ b/.claude/skills/sync-provider-pricing/skill.md @@ -1,9 +1,11 @@ # sync-provider-pricing Scrape each provider's **official** model + pricing page and reconcile our model -catalog — `provider-templates/*.json` (UI preset), `seed/fixtures/Model.json` (seed -fixture dataset), `seed.ts` cache multipliers, and the **prod `Model` table** — so -prices, model lists, and deprecation flags match the vendor's published page. +catalog. The single repo source of truth is `tools/db-migrate/model-catalog.json`; +`provider-templates/*.json` + `index.json` (UI preset) and `seed/fixtures/Model.json` +(seed dataset) are GENERATED from it, and `seed.ts` cache multipliers + the **prod +`Model` table** are the remaining surfaces — so prices, model lists, and deprecation +flags match the vendor's published page. Use this skill when: - A provider ships/renames/retires a model or changes prices. @@ -24,10 +26,11 @@ not edit that provider — it reports the gap. **Division of labor (why this is reliable):** the LLM does the part that genuinely needs judgment and freshness — fetch the live page, read the table, map vendor columns to our fields, decide deprecations — and emits a normalized desired-state JSON. The deterministic -`sync_pricing.py` does every mechanical step from that JSON (diff, edit template JSON, edit -`seed/fixtures/Model.json`, emit prod SQL) so the edits never drift. The script invents no -facts; the LLM verifies every fact live. Neither shortcut is allowed: no applying without a -live fetch, no hand-editing the JSON that the script should write. +`sync_pricing.py` does every mechanical step from that JSON (diff, edit +`model-catalog.json` + regenerate its derived files, emit prod SQL) so the edits never +drift. The script invents no facts; the LLM verifies every fact live. Neither shortcut is +allowed: no applying without a live fetch, no hand-editing the generated files (edit the +catalog and regenerate — CI `check:model-catalog` fails on any hand-edit). The mutable URL knob is **`provider-sources.json`** (next to this file): `models_urls` (model catalog/spec page) + `pricing_urls` ($/MTok) per provider. Edit it freely as vendor URLs @@ -35,24 +38,22 @@ drift; this procedure stays fixed. --- -## Where prices live (the 4 surfaces — keep them consistent) - -1. **`packages/control-plane-ui/public/provider-templates/.json`** — UI "add provider" - preset. Explicit per-model object: `code`, `name`, `description`, `providerModelId`, - `type` (chat|embedding|image|audio), `features[]`, `inputPricePerMillion`, - `outputPricePerMillion`, `cachedInputReadPricePerMillion`, `cachedInputWritePricePerMillion`, - `maxContextTokens`, `maxOutputTokens`. Also bump `modelCount` for this provider in - `provider-templates/index.json`. The build copies `public/` → `dist/`; after editing - `public/`, either rebuild the UI (`npm run build -w packages/control-plane-ui`) or mirror - the same edit into `dist/provider-templates/.json` so a no-rebuild deploy is current. -2. **`tools/db-migrate/seed/fixtures/Model.json`** — JSON array of `Model` rows (one object - per model, keys are camelCase column names). Carries all four price fields - (`inputPricePerMillion`, `outputPricePerMillion`, `cachedInputReadPricePerMillion`, - `cachedInputWritePricePerMillion`), plus `status`, `deprecationDate`, `replacedBy`, - `code`, `providerModelId`, `name`, etc. The fixture is the canonical data source for - fresh-seed runs; regenerate it from a source DB via - `tools/db-migrate/scripts/extract-reference-fixtures.ts`. For quick value fixes, - `sync_pricing.py apply` edits it in-place (matches rows by `code`). +## Where prices live (one source, two generated, plus seed.ts + prod) + +**0. `tools/db-migrate/model-catalog.json`** — THE source of truth you edit. One entry per +`(provider, model)`: shared vendor facts (`code`, `name`, `description`, `providerModelId`, +`type`, `features[]`, the four price fields, `maxContextTokens`, `maxOutputTokens`), an +`inTemplate` flag, and — for seeded models — a `seed` block (`id`, `status`, +`deprecationDate`, `replacedBy`, modalities, `capabilityJson`, timestamps). See the schema +header in `tools/db-migrate/gen-model-catalog.mjs`. `sync_pricing.py apply` edits this file +(money fields + seed `status`) and regenerates surfaces 1–2; never hand-edit those. + +1. **`packages/control-plane-ui/public/provider-templates/.json` + `index.json`** — + GENERATED. UI "add provider" preset: each model projected to the 12 wizard vendor-fact + fields, `index.json` `modelCount` computed. Do not edit by hand. +2. **`tools/db-migrate/seed/fixtures/Model.json`** — GENERATED. JSON array of the SEEDED + `Model` rows (those with a `seed` block), full camelCase column shape, sorted by `id`. + The seed's canonical dataset; do not edit by hand. 3. **`tools/db-migrate/seed/seed.ts`** — the cache-price backfill `VALUES (adapter, read, write)` block (search `cachePriceBackfill`). It fills `cachedInput{Read,Write}PricePerMillion` = `input × mult` **only when NULL** (COALESCE). It is a FALLBACK, not the truth — prefer @@ -155,7 +156,7 @@ Record the thinking + cache code-verification result (✓ / gap + file:line) in ### Step 3.5 — Approval gate (BINDING — no change without it) **Every price and every model change requires explicit user approval before it is -applied to ANY surface (template JSON, seed fixture, or prod).** Run +applied to ANY surface (the `model-catalog.json` source, or prod).** Run `sync_pricing.py diff ` and present its output to the user as the approval request — it pairs each change with the **source URL + fetch date** at the top so the user can confirm against the live page. For each drifted model show: field, old→new value @@ -164,21 +165,21 @@ prod-sql (Step 5) run ONLY after the user approves; if the user approves a subse reduced `desired.json` with only the approved models and re-run. Never apply silently, never batch-approve across providers — one provider's diff, one approval. -### Step 4 — Edit the repo surfaces (safe, reversible) -- Template JSON: update each model's price fields; add/flag models per Step 3; keep field order. - Bump `index.json` `modelCount`. Mirror into `dist/` or rebuild the UI. -- `tools/db-migrate/seed/fixtures/Model.json`: update all four price fields - (`inputPricePerMillion`, `outputPricePerMillion`, `cachedInputReadPricePerMillion`, - `cachedInputWritePricePerMillion`) and `status`/`deprecationDate`/`replacedBy` for the - matching rows (matched by `code`). The fixture is a JSON array — `sync_pricing.py apply` - edits it in-place with 2-space indent + trailing newline (preserves fixture format). For - manual edits: update only the changed values; do not reformat the whole file. +### Step 4 — Apply through the catalog (safe, reversible) +- `sync_pricing.py apply ` edits `model-catalog.json` (the four price fields on + the matched `(provider, code)` entry + seed `status`) and then regenerates + `provider-templates/*.json` + `index.json` + `Model.json` from it. One edit, one regenerate — + no per-surface hand-editing, no `dist/` mirror, no field-order care. If a desired model is + not yet in the catalog, apply reports it — add the entry (with a `seed` block if it should + seed) to the catalog first, then re-run. +- For a model needing more than price/status (a brand-new model, new features, caps): edit its + entry in `model-catalog.json` directly, then `npm run gen:model-catalog`. - `seed.ts`: only if the provider's whole-family read/write ratio changed. -- English only. Decimal literals, not "$5 / MTok". +- English only. Decimal literals, not "$5 / MTok". Verify with `npm run check:model-catalog`. ### Step 5 — prod is OUT OF SCOPE for this skill -This skill updates ONLY the repo surfaces — the template JSON and seed fixture. It -**never writes the prod `Model` table.** Changing prod prices is a SEPARATE operation that +This skill updates ONLY the repo source (`model-catalog.json`, which regenerates the +template JSON + seed fixture). It **never writes the prod `Model` table.** Changing prod prices is a SEPARATE operation that requires explicit, per-run user approval every time (there is no standing authority), done outside this skill under the prod-deploy backup discipline (pg_dump first, transactional `UPDATE ... WHERE code=... AND "providerId"=(...)`, re-SELECT to verify). The deterministic diff --git a/.claude/skills/sync-provider-pricing/sync_pricing.py b/.claude/skills/sync-provider-pricing/sync_pricing.py index 10fc2943..d4d15c2e 100644 --- a/.claude/skills/sync-provider-pricing/sync_pricing.py +++ b/.claude/skills/sync-provider-pricing/sync_pricing.py @@ -3,8 +3,12 @@ The LLM's only job is to WebFetch a provider's official pricing page and emit a normalized desired-state JSON (schema below). THIS script does every mechanical -step deterministically — diff, edit the template JSON, edit the Model fixture, -and emit transactional prod UPDATE SQL — so the reconcile never drifts. +step deterministically — diff, edit the single-source catalog + regenerate its +derived files, and emit transactional prod UPDATE SQL — so the reconcile never drifts. + +Source of truth: `tools/db-migrate/model-catalog.json`. `apply` edits THAT file and +regenerates `Model.json` + `provider-templates/*.json` + `index.json` from it — never +edit those derived files directly (CI `check:model-catalog` fails on drift). Desired-state JSON (one file per provider, produced by the LLM from the official page): { @@ -18,6 +22,9 @@ "output": 25.0, # $/MTok output "cache_write": 6.25, # $/MTok standard (short) cache write; 0 if none "cache_read": 0.5, # $/MTok cache hit/read; null for non-chat + "audio_input": 32.0, # $/MTok audio-in (realtime models only) + "audio_output": 64.0, # $/MTok audio-out (realtime models only) + "cached_audio_read": 0.4, # $/MTok cached audio-in read (realtime; null = no discount) "status": "active", # active | deprecated | disabled "replaced_by": null, # successor code if deprecated/retired "deprecation_date": null # ISO date if deprecated @@ -27,10 +34,9 @@ Only keys present are compared/applied; omit a key to leave that field untouched. Subcommands: - diff show drift: desired vs template JSON vs fixture - apply edit template JSON (+ index.json modelCount) and - tools/db-migrate/seed/fixtures/Model.json in place - (input/output/status) + diff show drift: desired vs the generated template + fixture + apply edit model-catalog.json (money fields + seed status) + then regenerate Model.json + provider-templates prod-sql [--out f] emit BEGIN/UPDATE.../COMMIT for the prod Model table Paths default to repo-relative; override with --repo. Money compared at 1e-9 tolerance. @@ -46,27 +52,38 @@ "output": "outputPricePerMillion", "cache_write": "cachedInputWritePricePerMillion", "cache_read": "cachedInputReadPricePerMillion", + # Realtime models: audio-token rates, USD per 1M tokens. + "audio_input": "audioInputPricePerMillion", + "audio_output": "audioOutputPricePerMillion", + "cached_audio_read": "cachedAudioInputReadPricePerMillion", } -# fixture + prod carry all 4 money fields. +# fixture + prod carry all 7 money fields. FIXTURE_MONEY = { "input": "inputPricePerMillion", "output": "outputPricePerMillion", "cache_write": "cachedInputWritePricePerMillion", "cache_read": "cachedInputReadPricePerMillion", + "audio_input": "audioInputPricePerMillion", + "audio_output": "audioOutputPricePerMillion", + "cached_audio_read": "cachedAudioInputReadPricePerMillion", } PROD_FIELDS = { "input": "inputPricePerMillion", "output": "outputPricePerMillion", "cache_write": "cachedInputWritePricePerMillion", "cache_read": "cachedInputReadPricePerMillion", + "audio_input": "audioInputPricePerMillion", + "audio_output": "audioOutputPricePerMillion", + "cached_audio_read": "cachedAudioInputReadPricePerMillion", } def repo_paths(repo): return { "template_dir": os.path.join(repo, "packages/control-plane-ui/public/provider-templates"), - "dist_dir": os.path.join(repo, "packages/control-plane-ui/dist/provider-templates"), "fixture": os.path.join(repo, "tools/db-migrate/seed/fixtures/Model.json"), + "catalog": os.path.join(repo, "tools/db-migrate/model-catalog.json"), + "gen": os.path.join(repo, "tools/db-migrate/gen-model-catalog.mjs"), } @@ -104,13 +121,6 @@ def load_fixture(fixture_path): return rows, by_code -def save_fixture(fixture_path, rows): - """Write Model.json with 2-space indent + trailing newline (matches fixture format).""" - with open(fixture_path, "w", encoding="utf-8") as f: - json.dump(rows, f, indent=2, ensure_ascii=False) - f.write("\n") - - def num_literal(v): # Emit a decimal literal suitable for prod UPDATE SQL (30-dp style, trailing zeros stripped). return f"{Decimal(str(v)):.30f}".rstrip("0").rstrip(".") if v is not None else "NULL" @@ -140,14 +150,16 @@ def cmd_diff(args): for k, tk in TEMPLATE_FIELDS.items(): if k in want and (t is None or not money_eq(want[k], t.get(tk))): marks.append(f"{k}: {fmt(None if t is None else t.get(tk))}→{fmt(want[k])}") - if "status" in want and t is not None and want["status"] != t.get("status", "active"): - marks.append(f"status: {t.get('status','active')}→{want['status']}") if t is None: marks.append("NOT in template (add?)") s = fixture_by.get(code) for k, fk in FIXTURE_MONEY.items(): if k in want and s is not None and not money_eq(want[k], s.get(fk)): marks.append(f"fixture.{k}: {fmt(s.get(fk))}→{fmt(want[k])}") + # status is a seed-only field: it lives on the Model.json (fixture) row, not + # the wizard template. Compare against the fixture so the preview is accurate. + if "status" in want and s is not None and s.get("status", "active") != want["status"]: + marks.append(f"status: {s.get('status','active')}→{want['status']}") if marks: drift += 1 print(f" {code}: " + " | ".join(marks)) @@ -163,73 +175,47 @@ def cmd_diff(args): def cmd_apply(args): provider, desired, _ = load_desired(args.desired) p = repo_paths(args.repo) - # 1) template JSON (+ dist mirror + index.json modelCount) - for d in (p["template_dir"], p["dist_dir"]): - tpath = os.path.join(d, f"{provider}.json") - if not os.path.exists(tpath): - continue - tpl = json.load(open(tpath)) - changed = 0 - by = {m["code"]: m for m in tpl.get("models", [])} - for code, want in desired.items(): - m = by.get(code) - if m is None: - continue - for k, tk in TEMPLATE_FIELDS.items(): - if k in want and not money_eq(want[k], m.get(tk)): - m[tk] = want[k]; changed += 1 - if "status" in want and want["status"] != m.get("status", "active"): - m["status"] = want["status"]; changed += 1 - with open(tpath, "w") as f: - json.dump(tpl, f, indent=2, ensure_ascii=False) - f.write("\n") - print(f" template {os.path.relpath(tpath, args.repo)}: {changed} field(s) updated") - _bump_index(p, provider, desired, args.repo) - # 2) Model.json fixture — update inputPricePerMillion / outputPricePerMillion / - # cachedInputReadPricePerMillion / cachedInputWritePricePerMillion / status in place. - _apply_fixture(p["fixture"], desired, args.repo) - return 0 - - -def _bump_index(p, provider, desired, repo): - for d in (p["template_dir"], p["dist_dir"]): - ipath = os.path.join(d, "index.json") - if not os.path.exists(ipath): - continue - idx = json.load(open(ipath)) - tpath = os.path.join(d, f"{provider}.json") - cnt = len(json.load(open(tpath)).get("models", [])) if os.path.exists(tpath) else None - for t in idx.get("templates", []): - if t.get("name") == provider and cnt is not None and t.get("modelCount") != cnt: - t["modelCount"] = cnt - with open(ipath, "w") as f: - json.dump(idx, f, indent=2, ensure_ascii=False) - f.write("\n") - - -def _apply_fixture(fixture_path, desired, repo): - """Update Model.json fixture rows for the models in desired. - Matches by code. Writes back with 2-space indent + trailing newline.""" - rows, by_code = load_fixture(fixture_path) - if not rows: - print(f" fixture {os.path.relpath(fixture_path, repo)}: file not found or empty — skipped") - return - changed = 0 + with open(p["catalog"], encoding="utf-8") as f: + catalog = json.load(f) + block = next((b for b in catalog.get("providers", []) if b.get("key") == provider), None) + if block is None: + sys.exit(f"apply: provider '{provider}' not found in {os.path.relpath(p['catalog'], args.repo)}") + by_code = {m["code"]: m for m in block.get("models", [])} + changed, missing = 0, [] for code, want in desired.items(): - row = by_code.get(code) - if row is None: + m = by_code.get(code) + if m is None: + missing.append(code) continue + # Money fields are shared on the model entry — one edit feeds both the + # generated Model.json row and the wizard template. for k, fk in FIXTURE_MONEY.items(): - if k in want: - if not money_eq(want[k], row.get(fk)): - row[fk] = want[k] - changed += 1 - if "status" in want and row.get("status") != want["status"]: - row["status"] = want["status"] + if k in want and not money_eq(want[k], m.get(fk)): + m[fk] = want[k] + changed += 1 + # status is a seed-only field (Model.json); wizard templates carry none. + if "status" in want and isinstance(m.get("seed"), dict) and m["seed"].get("status") != want["status"]: + m["seed"]["status"] = want["status"] changed += 1 - if changed: - save_fixture(fixture_path, rows) - print(f" fixture {os.path.relpath(fixture_path, repo)}: {changed} field(s) updated") + with open(p["catalog"], "w", encoding="utf-8") as f: + json.dump(catalog, f, indent=2, ensure_ascii=False) + f.write("\n") + print(f" {os.path.relpath(p['catalog'], args.repo)}: {changed} field(s) updated") + if missing: + print(f" NOTE: {len(missing)} desired model(s) not in the catalog — add them there first: {missing}") + _regenerate(p, args.repo) + return 0 + + +def _regenerate(p, repo): + """Regenerate Model.json + provider-templates/*.json + index.json from the catalog.""" + import subprocess + r = subprocess.run(["node", p["gen"]], cwd=repo, capture_output=True, text=True) + if r.stdout: + sys.stdout.write(r.stdout) + if r.returncode != 0: + sys.stderr.write(r.stderr) + sys.exit(f"apply: gen-model-catalog.mjs failed (exit {r.returncode})") def cmd_prod_sql(args): diff --git a/.claude/skills/test-openai-responses/run.sh b/.claude/skills/test-openai-responses/run.sh index 9fc572df..75ca9961 100755 --- a/.claude/skills/test-openai-responses/run.sh +++ b/.claude/skills/test-openai-responses/run.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # /test-openai-responses runner. # -# Drives 5 hand-rolled requests against POST /v1/responses (E56 ingress) +# Drives 5 hand-rolled requests against POST /v1/responses (ingress) # on the local AI Gateway, then cross-checks the resulting traffic_event # DB rows + Prometheus counters. Optional --cross-format runs the S6 # guard rejections against a non-OpenAI routing rule. diff --git a/.cursor/rules/code-doc-lockstep.mdc b/.cursor/rules/code-doc-lockstep.mdc index 2049e5af..24656e5c 100644 --- a/.cursor/rules/code-doc-lockstep.mdc +++ b/.cursor/rules/code-doc-lockstep.mdc @@ -48,6 +48,7 @@ node scripts/check-doc-lockstep.mjs --staged # only against staged files - A passing `tsc -b` / `go build` / unit tests do **not** mean docs are aligned. Code can compile and the doc can still claim something the code no longer does. - Touching `updated:` in the front-matter without changing the body is a **red flag in review** — the doc should reflect what the code now does, not just a fresh date. - Adding a `TODO: update doc` comment in code is **explicitly forbidden** under the "real implementation only" rule. Update the doc now, or carve out the change. +- **This gate verifies presence, never content.** A satisfied entry means a mapped doc was *touched* — not that it reflects the change. When more than one code file triggers the same entry, a single touched doc satisfies it, so a doc that captures one change but not its siblings passes silently (how a stale provider-adapter doc once shipped: one adapter commit touched the doc, a second adapter's function deletion did not). The check prints a non-blocking **COVERAGE ADVISORY** listing that fan-in — treat it as a per-file review checklist. Verifying the doc actually matches the code is `doc-review`'s job ([[feedback-docs-through-review-skill]]), not this gate's, and it is not optional. Tightening the gate to per-file/per-commit was considered and rejected: it false-positives on the legitimate "one PR, many files, one shared doc section" case and still can't catch a whitespace-only touch — presence checks cannot approximate content checks. ## Waiver diff --git a/.env.example b/.env.example index 0d628bee..cd5302e4 100644 --- a/.env.example +++ b/.env.example @@ -163,10 +163,14 @@ COMPLIANCE_PROXY_API_TOKEN=CHANGE_ME_COMPLIANCE_PROXY_API_TOKEN # "zstd" trades CPU for a smaller frame. # NEXUS_AUDIT_WIRE — gw→hub audit wire. DEFAULT "binary" (the Hub dual-reads). # "json" reverts to the legacy text wire. -# AI_GATEWAY_AUDIT_LOSS_MODE — audit overflow policy. DEFAULT "spill" -# (non-blocking spill-defer: no loss until the spill channel + disk are -# saturated; drops past that are counted on dropped_total). "block" = strict -# back-pressure (never drops, slows the request path); "drop" = bounded loss. +# AI_GATEWAY_AUDIT_LOSS_MODE — audit overflow policy. DEFAULT "spillblock" +# (no-loss: the durable on-disk spool is the primary overflow buffer, and the +# request path back-pressures only when that spool is ALSO saturated). +# "block" = back-pressure at the in-heap queue, bounded at 10s, then a durable +# spool write (a counted drop only if no spool is wired). LOSSY opt-outs, for callers that do not need a compliance +# audit trail: "spill" (async durable spill, counted drop once it saturates) +# and "drop" (counted bounded drop). An empty or unrecognised value resolves +# to "spillblock", so a typo can never make the trail lossy. # NEXUS_QUOTA_WRITE_BEHIND / NEXUS_CREDSTATS_WRITE_BEHIND — defer quota and # credential-stats Redis writes off the request hot path (flush on an interval, # final drain on graceful shutdown). DEFAULT ON (soft quota). Overshoot per @@ -314,18 +318,56 @@ NATS_URL=nats://localhost:4222 # disables; a non-numeric value falls back to auto with a warning. # AI_GATEWAY_MAX_INFLIGHT=auto -# Audit overflow policy. Durable audit is a product promise + compliance -# requirement. DEFAULT "spill" is spill-defer: the request path never -# back-pressures — overflow goes to the durable on-disk spool and the -# spill-recovery sweeper replays it to Postgres. No loss UNTIL the in-process -# spill channel + disk are saturated; under sustained overload past that point -# records are dropped and counted on dropped_total (never silently). This lifts -# clean-path RPS past the block-mode ceiling. Alternatives: "block" = hard -# synchronous back-pressure (never drops — slows the request path until the audit -# pipeline drains; strictest compliance posture); "drop" = counted bounded drop -# (lossy, non-compliance only). Empty/unknown → "block" (never silently lossy -# from a typo). -# AI_GATEWAY_AUDIT_LOSS_MODE=spill +# Built-in per-VK caps for expensive generative endpoints (e88 NFR-4). These +# close the billing-DoS surface where one leaked/abusive virtual key opens +# unbounded concurrent per-call-priced requests — the global admission gate +# above is process-wide, not per-VK. Defaults are code constants; override only +# to tune. CONCURRENCY = max simultaneous in-flight requests of that kind PER +# VK (0 = unlimited — disabling a DoS control logs a WARN). MAX_BYTES = a +# per-kind request-body ceiling, tighter than payload_capture.maxRequestBytes. +# NOTE: raising CONCURRENCY is ALL-VK on this single-tenant box, not per-key. +# AI_GATEWAY_GENERATIVE_CAP_IMAGE_GENERATION_CONCURRENCY=4 +# AI_GATEWAY_GENERATIVE_CAP_IMAGE_GENERATION_MAX_BYTES=262144 +# AI_GATEWAY_GENERATIVE_CAP_TTS_CONCURRENCY=8 +# AI_GATEWAY_GENERATIVE_CAP_TTS_MAX_BYTES=262144 +# AI_GATEWAY_GENERATIVE_CAP_VIDEO_GENERATION_CONCURRENCY=2 +# AI_GATEWAY_GENERATIVE_CAP_VIDEO_GENERATION_MAX_BYTES=16777216 +# Video render bound: max NON-TERMINAL jobs (queued/in_progress) per VK — the +# real bound on concurrent paid renders (VIDEO_GENERATION_CONCURRENCY above +# bounds only in-flight HTTP requests). 429 VIDEO_JOBS_LIMIT at the cap. +# AI_GATEWAY_VIDEO_MAX_NONTERMINAL_JOBS=4 +# AI_GATEWAY_GENERATIVE_CAP_STT_CONCURRENCY=4 +# AI_GATEWAY_GENERATIVE_CAP_STT_MAX_BYTES=27262976 +# AI_GATEWAY_GENERATIVE_CAP_GUARDRAIL_CONCURRENCY=4 +# AI_GATEWAY_GENERATIVE_CAP_GUARDRAIL_MAX_BYTES=1048576 +# Realtime counts long-lived WebSocket SESSIONS per VK (acquired at upgrade, +# released at session close), and its MAX_BYTES is the per-WS-FRAME ceiling +# enforced via SetReadLimit on both legs — not an HTTP body cap. One +# server-to-server VK serves a whole product: the default 2 is a dark-launch +# floor; production realtime use requires raising CONCURRENCY (all-VK). +# AI_GATEWAY_GENERATIVE_CAP_REALTIME_CONCURRENCY=2 +# AI_GATEWAY_GENERATIVE_CAP_REALTIME_MAX_BYTES=16777216 + +# Audit overflow policy (shared/audit/lossmode; the same four modes the +# compliance proxy and the agent use). Durable audit is a product promise + +# compliance requirement, so the DEFAULT "spillblock" is NO-LOSS: on a full +# in-heap buffer, overflow goes to the durable on-disk spool off the request +# path, and the request path back-pressures ONLY when that large spool is also +# saturated — including on a full spool quota, where the spill worker keeps the +# batch and retries while the recovery sweeper frees space, so ingest +# self-throttles to the drain rate instead of shedding records. +# +# Alternatives: "block" = back-pressure at the in-heap queue, which stalls sooner +# and is bounded at 10s; past that bound it falls back to a durable spool write, +# and counts a drop only if no spool is wired. LOSSY opt-outs, for callers that do not +# need a compliance audit trail: "spill" = async durable spill with a counted +# drop once that path is also saturated; "drop" = counted bounded drop, maximum +# throughput. Both count on dropped_total — never silently. +# +# Empty or unrecognised → "spillblock". The values are matched exactly and in +# lower case, and anything else resolves to the no-loss default rather than +# being rejected, so a typo can never make the audit trail lossy. +# AI_GATEWAY_AUDIT_LOSS_MODE=spillblock # End-to-end zstd compression of large captured audit bodies. The producer # compresses off the request path (async marshal worker), the body rides the @@ -373,12 +415,16 @@ NATS_URL=nats://localhost:4222 # read this to register as Things on the Hub via thingclient. NEXUS_HUB_URL=http://127.0.0.1:3060 -# Other service URLs — only consumed by Control Plane's BFF when proxying -# admin UI calls. ai-gateway / compliance-proxy do NOT call CP, so these -# only matter for CP. -AI_GATEWAY_URL=http://127.0.0.1:3050 -COMPLIANCE_PROXY_URL=http://127.0.0.1:3040 -COMPLIANCE_PROXY_RUNTIME_URL=http://127.0.0.1:3040 +# Peer service URLs are NOT configured — a service never configures another +# Nexus service's address. Each service reports its own publicURL + privateURL +# to the Hub at registration, and peers resolve the Hub-reported value at +# request time (shared/transport/peerurl). Each service's own *_PRIVATE_URL +# (optional, below) overrides its auto-detected private IPv4 — set only for +# split-horizon or non-default topologies. +# NEXUS_HUB_PRIVATE_URL=http://10.0.0.10:3060 +# CONTROL_PLANE_PRIVATE_URL=http://10.0.0.11:3001 +# AI_GATEWAY_PRIVATE_URL=http://10.0.0.12:3050 +# COMPLIANCE_PROXY_PRIVATE_URL=http://10.0.0.13:3040 # Shared OAuth/OIDC auth server. CP runs the issuer side; Hub runs the # verifier side. Both read AUTH_SERVER_ISSUER and verify it matches — @@ -546,3 +592,45 @@ NEXUS_ASSISTANT_SYSTEM_VK= # VITE_CLIENT_ERROR_REPORTING_URL=https://errors.example.com/ingest # Optional release / build id included in client error reports (e.g. git SHA). # VITE_APP_RELEASE= + +# ── Vendor bill reconciliation (nexus-hub) ─────────────────────────────────── +# Admin/org keys the daily reconciliation job uses to read each provider's +# AUTHORITATIVE billed USD from the vendor's cost API (distinct from the +# inference credentials stored in the DB). Secret → env-only, never yaml. +# A provider whose key is unset is simply skipped (not reconciled). +# Full setup + troubleshooting: docs/operators/ops/runbooks/vendor-bill-reconciliation.md +# +# The key TYPE matters — both endpoints are organization-management APIs and +# reject ordinary inference keys: +# OpenAI must be an ORGANIZATION Admin key (platform.openai.com → +# Settings → Organization → Admin keys, requires Owner role). +# A project key or service-account key returns +# 403 "Missing scopes: api.usage.read" no matter what permissions +# are granted to it. +# Anthropic must be an Admin key (sk-ant-admin01-…) created in the Console +# under Admin keys. A normal sk-ant-api03-… inference key returns +# 401 "invalid x-api-key". +# +# OpenAI org admin key (Bearer) for GET /v1/organization/costs. +# OPENAI_COST_ADMIN_KEY= +# Anthropic admin key (sk-ant-admin01-...) for GET /v1/organizations/cost_report. +# ANTHROPIC_COST_ADMIN_KEY= +# +# ── Scope pins (optional, but reconciliation is near-useless without them) ──── +# An admin key reports the WHOLE organization. If the gateway is one consumer +# among several (other API keys, other products, other teams), the vendor total +# is not comparable to the gateway's spend: the row lands as coverage=org_only, +# shows ~100% difference, and never alerts. Pinning narrows the vendor number to +# just this gateway. +# +# OpenAI: narrows via the endpoint's api_key_ids filter — exact per-key +# attribution. Use the id (key_...), NOT the secret, of the key the gateway +# authenticates with. Find it in the OpenAI console, or by grouping costs by +# api_key_id and matching the daily curve against the gateway's own estimate. +# OPENAI_COST_API_KEY_ID= +# +# Anthropic: cost_report exposes NO per-key cost and NO filter parameters, so +# workspace is the finest unit available and filtering happens client-side. +# Requires a NAMED workspace containing the gateway's key — the default +# workspace is reported with a null workspace_id and can never be scoped. +# ANTHROPIC_COST_WORKSPACE_ID= diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 621419ca..4e632714 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -20,15 +20,31 @@ cd "$REPO_ROOT" staged="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)" +# Deletion-inclusive staged list for the coverage gates only: deleting or +# renaming a test file lowers its package's coverage exactly like editing +# one, so those gates must also fire on D/R. --no-renames decomposes a +# rename into its delete+add halves so both affected packages are seen. +# Content-scanning gates (lint, gofmt, tokens) stay on $staged — a deleted +# file has no content to scan. +staged_cov="$(git diff --cached --name-only --no-renames --diff-filter=ACMD 2>/dev/null || true)" + FAILED=0 run_hard() { - # run_hard