From 41e322c53d7b4b796eb377d0df9c29ecd10ba431 Mon Sep 17 00:00:00 2001
From: Chia
Date: Thu, 6 Aug 2026 09:29:41 +1200
Subject: feat: complete commercial control plane, billing, auth, and model
catalog
- add PostgreSQL control-plane persistence with Redis-degraded hot reload
- implement prepaid balance, usage ledger, Stripe top-up and reconciliation
- add registration, email verification, password reset, invitations and RBAC
- support TOTP, Passkey MFA, device sessions, quotas and rate limits
- add tenant billing profiles, audit logs and operational readiness checks
- build authenticated admin console, Quickstart, Playground and usage analytics
- add public model catalog with pricing, filtering and cost estimation
- support OpenAI Responses providers and provider health failover
- validate real upstream usage reporting and balance settlement
---
.env.control.example | 5 +-
Dockerfile | 3 +
README.md | 88 +++-
cmd/aigw/main.go | 11 +-
config.control.example.json | 3 +-
config.example.json | 4 +
config.local.json | 4 +
config.responses.example.json | 47 ++
docker-compose.yml | 29 +-
docs/architecture.md | 84 +++-
docs/commercial-readiness.md | 98 +++--
internal/adminapi/api.go | 470 +++++++++++++++++++-
internal/adminui/assets/app.js | 307 +++++++++++++-
internal/adminui/assets/index.html | 139 +++++-
internal/adminui/assets/models.css | 64 +++
internal/adminui/assets/models.html | 50 +++
internal/adminui/assets/models.js | 126 ++++++
internal/adminui/assets/style.css | 132 +++++-
internal/adminui/ui.go | 11 +-
internal/auth/static.go | 37 +-
internal/auth/static_test.go | 31 ++
internal/billing/auto_topup.go | 541 ++++++++++++++++++++++++
internal/billing/auto_topup_test.go | 155 +++++++
internal/billing/ledger.go | 8 +-
internal/billing/operations.go | 84 +++-
internal/billing/profile.go | 197 +++++++++
internal/billing/profile_test.go | 135 ++++++
internal/billing/service.go | 66 ++-
internal/billing/service_test.go | 67 +++
internal/billing/stripe.go | 25 +-
internal/billing/types.go | 88 +++-
internal/catalog/catalog.go | 21 +-
internal/catalog/catalog_test.go | 10 +
internal/config/config.go | 64 +++
internal/config/config_test.go | 94 ++++
internal/controlplane/access.go | 11 +-
internal/controlplane/access_test.go | 4 +
internal/controlplane/mail_operations.go | 13 +-
internal/controlplane/mutations.go | 82 +++-
internal/controlplane/preferences.go | 156 +++++++
internal/controlplane/preferences_test.go | 31 ++
internal/controlplane/queries.go | 191 ++++++++-
internal/controlplane/queries_test.go | 80 ++++
internal/controlplane/schema.sql | 110 +++++
internal/controlplane/snapshot.go | 35 +-
internal/controlplane/store.go | 4 +-
internal/controlplane/store_integration_test.go | 167 ++++++++
internal/controlplane/types.go | 223 +++++++++-
internal/controlplane/usage.go | 128 +++++-
internal/controlplane/usage_analytics.go | 215 ++++++++++
internal/controlplane/usage_analytics_test.go | 34 ++
internal/controlplane/usage_integration_test.go | 152 +++++++
internal/domain/types.go | 40 +-
internal/httpapi/api.go | 175 +++++++-
internal/httpapi/api_test.go | 281 +++++++++++-
internal/operations/operations.go | 164 ++++---
internal/operations/operations_test.go | 89 ++++
internal/provider/forwarder.go | 56 ++-
internal/provider/forwarder_test.go | 21 +
internal/providerhealth/tracker.go | 200 +++++++++
internal/providerhealth/tracker_test.go | 50 +++
internal/routing/router.go | 55 ++-
internal/routing/router_test.go | 94 ++++
internal/usage/observer.go | 52 ++-
internal/usage/observer_test.go | 22 +-
scripts/start-debug.sh | 97 +----
scripts/stop-debug.sh | 66 ++-
67 files changed, 5973 insertions(+), 423 deletions(-)
create mode 100644 config.responses.example.json
create mode 100644 internal/adminui/assets/models.css
create mode 100644 internal/adminui/assets/models.html
create mode 100644 internal/adminui/assets/models.js
create mode 100644 internal/billing/auto_topup.go
create mode 100644 internal/billing/auto_topup_test.go
create mode 100644 internal/billing/profile.go
create mode 100644 internal/billing/profile_test.go
create mode 100644 internal/controlplane/preferences.go
create mode 100644 internal/controlplane/preferences_test.go
create mode 100644 internal/controlplane/queries_test.go
create mode 100644 internal/controlplane/usage_analytics.go
create mode 100644 internal/controlplane/usage_analytics_test.go
create mode 100644 internal/controlplane/usage_integration_test.go
create mode 100644 internal/operations/operations_test.go
create mode 100644 internal/providerhealth/tracker.go
create mode 100644 internal/providerhealth/tracker_test.go
diff --git a/.env.control.example b/.env.control.example
index 5020f32..fd29a99 100644
--- a/.env.control.example
+++ b/.env.control.example
@@ -20,6 +20,7 @@ AIGW_CREDENTIAL_KEY=replace-with-base64-32-byte-key
AIGW_CREDENTIAL_PREVIOUS_KEYS=
AIGW_ADMIN_TOKEN=replace-with-a-long-random-admin-token
AIGW_PUBLIC_URL=http://localhost:8081/admin/
+AIGW_INFERENCE_PUBLIC_URL=http://localhost:8080
AIGW_WEBAUTHN_RP_ID=localhost
AIGW_WEBAUTHN_ORIGINS=http://localhost:8081
AIGW_SMTP_FROM_ADDRESS=no-reply@aigw.local
@@ -30,7 +31,9 @@ AIGW_SMTP_USERNAME=
AIGW_SMTP_PASSWORD=
# HMAC-SHA256 secret for normalized bounce/complaint callbacks. Inject from a secret manager.
AIGW_MAIL_FEEDBACK_SECRET=
-# Prefer a restricted test key (rk_test_) with Checkout Session write access.
+# Prefer a restricted test key (rk_test_) with only the permissions documented in README.
+# Automatic top-up additionally requires Setup Intents Read and Payment Intents Write.
+AIGW_STRIPE_ENABLED=false
AIGW_STRIPE_API_KEY=replace-with-a-stripe-restricted-key
# Development only: use a separate key with Debugging Tools Write for Stripe CLI.
AIGW_STRIPE_CLI_API_KEY=replace-with-a-separate-cli-restricted-key
diff --git a/Dockerfile b/Dockerfile
index f7d2c28..fb88a47 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,6 +8,9 @@ COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags="-s -w" -o /out/aigw ./cmd/aigw
+FROM build AS test
+CMD ["go", "test", "./..."]
+
FROM alpine:3.22
RUN apk add --no-cache ca-certificates && adduser -D -H -u 10001 aigw
USER aigw
diff --git a/README.md b/README.md
index fbf22a4..45bf077 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,32 @@
# AIGW
-AIGW 是一个轻量、无状态的 AI API 中转后端。当前阶段聚焦上游接入与 API 分发:提供 OpenAI Chat Completions 和 Anthropic Messages 兼容入口,支持公开模型名映射、多上游路由、加权分流、故障转移、SSE 直通、客户密钥鉴权以及异步用量事件。
+AIGW 是一个轻量、无状态的 AI API 中转后端。当前阶段聚焦上游接入与 API 分发:提供 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 兼容入口,支持公开模型名映射、多上游路由、加权分流、故障转移、SSE 直通、客户密钥鉴权以及异步用量事件。
它借鉴了 ZenMux 的双协议、`provider/model` 模型命名、统一错误、请求 ID、路由和可观测性边界,但没有复制其业务实现。
## 当前能力
-- OpenAI:`POST /v1/chat/completions`、`GET /v1/models`
+- OpenAI:`POST /v1/chat/completions`、`POST /v1/responses`、`GET /v1/models`
- Anthropic:`POST /anthropic/v1/messages`、`GET /anthropic/v1/models`
- ZenMux 风格别名:所有入口同时提供 `/api/...` 路径
- 一个公开模型可配置多个同协议上游,低 `priority` 优先,同级按 `weight` 分流
+- 请求可用 `model-id:provider-slug` 固定到模型目录公开的某个供应商;固定后不会回退到其他供应商
- 在 429、502、503、504 或连接失败时,于响应开始前自动尝试下一条路由
+- 每条模型/供应商 route 记录真实请求的近期可用率和响应头延迟;连续 3 次可重试失败后熔断 30 秒,冷却期间路由自动绕行
- SSE 增量直通、主动断连传播、共享 HTTP/2 连接池
- `Authorization: Bearer` 和 `x-api-key` 客户鉴权
- 统一 JSON 错误、`X-AIGW-Request-ID`、Prometheus 文本指标
- 非阻塞用量事件,包含租户、项目、模型、上游、尝试次数、耗时和 token 用量
- PostgreSQL 预付余额、请求额度冻结、实际 token 结算和不可变账本
- Stripe 托管 Checkout 充值、签名 Webhook 与事件/订单双重幂等
+- Stripe 托管支付方式保存与低余额自动充值,off-session PaymentIntent 失败会暂停自动充值并提示客户处理
- PostgreSQL Usage Ledger、月度项目汇总和单请求成本追溯
- 邮箱验证、邀请注册、密码重置、登录限流、可撤销设备会话和管理 API 审计日志
- TOTP(含一次性恢复码)与 WebAuthn Passkey 注册、二次验证和无密码登录
- 六种 RBAC 角色、租户数据隔离和 CSRF 防护
- 项目级 RPM、估算 TPM、并发限制和月度消费配额
+- API Key 级模型白名单、月度消费上限、过期时间、标签和最后使用时间
+- 无需登录的 `/admin/models` 模型与价格目录,支持搜索、协议/输入/开发者筛选、详情和 token 成本估算
## 快速运行
@@ -40,6 +45,11 @@ go run ./cmd/aigw -config config.json
如果当前只有一种上游,从 `config.json` 删除未使用的 provider 和对应 model,避免启动时要求该密钥。
+控制面模式下,公开目录位于 `AIGW_PUBLIC_URL` 同源的 `/models`(本地默认
+`http://localhost:8081/admin/models`),匿名数据接口为
+`GET /admin/api/public/models`。公开响应只包含全局可用模型的公开 ID、能力、
+协议、价格与聚合可用性;租户/Key 限定模型和内部路由字段不会返回。
+
不使用真实密钥的本地体验方式:
```bash
@@ -65,6 +75,21 @@ curl http://127.0.0.1:8080/v1/chat/completions \
-d '{"model":"openai/gpt-4.1-mini","messages":[{"role":"user","content":"hello"}],"stream":true}'
```
+OpenAI Responses 供应商使用独立的 wire API 配置,不会与 Chat Completions 隐式互转:
+
+```bash
+export OPENAI_BASE_URL='https://your-provider.example/v1'
+export OPENAI_API_KEY='your-upstream-key'
+export AIGW_SERVER_ADDRESS='127.0.0.1:18081'
+export AIGW_API_KEYS='[{"key":"sk-local-change-me","key_id":"local-key","tenant_id":"tenant-demo","project_id":"project-default","scopes":["inference"]}]'
+go run ./cmd/aigw -config config.responses.example.json
+
+curl http://127.0.0.1:18081/v1/responses \
+ -H 'Authorization: Bearer sk-local-change-me' \
+ -H 'Content-Type: application/json' \
+ -d '{"model":"openai/gpt-5.5","input":"Reply with OK.","max_output_tokens":32}'
+```
+
Anthropic 调用示例:
```bash
@@ -77,7 +102,7 @@ curl http://127.0.0.1:8080/anthropic/v1/messages \
## 配置路由
-每条 route 把一个对外模型映射到一个上游模型:
+每条 route 把一个对外模型映射到一个上游模型。供应商的 `protocol` 表示 OpenAI/Anthropic 协议族,`wire_api` 表示实际调用 `chat_completions`、`responses` 或 `messages`。静态配置可为供应商设置唯一的公开 `slug`;省略时使用符合相同格式的 `id`:
```json
{
@@ -93,14 +118,26 @@ curl http://127.0.0.1:8080/anthropic/v1/messages \
这里 A/B 承担约 80/20 的首选流量,C 只作为更低优先级的后备。所有上游密钥仅通过 `api_key_env` 指向的环境变量读取。客户密钥从 `AIGW_API_KEYS` JSON 数组读取,进程内只保存 SHA-256 摘要。
+默认请求只传基础模型 ID,由网关自动进行权重分流、熔断绕行和故障转移。需要复现特定供应商行为时,可以先从 `GET /v1/models` 的 `providers` 字段读取公开 slug,再把它附加到模型名:
+
+```bash
+curl http://127.0.0.1:8080/v1/chat/completions \
+ -H 'Authorization: Bearer sk-local-change-me' \
+ -H 'Content-Type: application/json' \
+ -d '{"model":"vendor/model-public:provider-b","messages":[{"role":"user","content":"hello"}]}'
+```
+
+指定供应商时,路由器只会尝试该 slug 下与当前 API 协议兼容的 route,不会静默切换到其他供应商。该供应商不存在时返回 `404 provider_not_found`,正在熔断冷却时返回 `503 provider_unavailable`。API Key 模型白名单仍按基础模型 ID 校验,用量与扣费也归集到基础模型,避免供应商后缀拆分账单。
+
## 生产边界
当前版本可以作为带预付计费的数据面,并已把控制面、账务和运营入口拆开:
- 控制面计费模式会把 UsageEvent、冻结记录和扣费流水同步、幂等写入 PostgreSQL;响应结束只负责把结算事件投递到持久化队列,worker 负责重试、过期冻结恢复和本地 JSONL spool 补偿。
- 客户密钥和模型目录在控制面模式下从 PostgreSQL 载入到原子内存快照;Redis 只是可选的变更广播加速层,故障时通过 PostgreSQL generation 轮询收敛。
-- 当前只把 OpenAI 入口发给 OpenAI 兼容上游、Anthropic 入口发给 Anthropic 兼容上游,不做跨协议转换。
+- 当前只把 Chat Completions、Responses、Anthropic Messages 入口发给相同 wire API 的兼容上游,不做隐式跨协议转换。
- 自动故障转移可能在极少数网络错误下造成上游重复执行。正式计费时需要上游幂等能力、请求去重策略和重复成本对账。
+- 供应商健康状态是每个网关实例基于真实流量维护的 100 次滑动窗口,不是主动探测、全局 SLA 或首 token 延迟;新 route 在首个请求前显示为未采样。熔断状态不写入 PG/Redis,实例重启后重新学习。
- 计费上游必须返回 usage。OpenAI 流请求会强制请求 `stream_options.include_usage=true`;成功响应缺少 usage 时不会按零费用放行,也不会猜测 token,而是把授权保持为 `metering_failed`、触发 readiness/Prometheus 告警,直到运营切断或修复该上游。显式的零 token usage 仍可正常结算。
- 默认生产配置使用独立的推理、管理、支付/邮件 Webhook 和 operations listener。`/healthz` 只表示进程存活,`/readyz` 会检查 PG、快照、Stripe 对账、Webhook、退款、未收款、缺失用量、结算队列和邮件积压;Redis 是可降级传播层。`/metrics` 应仅在内网暴露;公网 TLS、WAF 和连接层限速应放在负载均衡器或边缘代理。
@@ -122,19 +159,23 @@ curl http://127.0.0.1:8080/anthropic/v1/messages \
./scripts/stop-debug.sh
```
-关闭脚本保留 PostgreSQL/Redis 数据卷,下一次启动仍可继续使用已有控制面数据。需要测试 Stripe Checkout 时,把 `.env.debug` 中的 restricted key、Webhook signing secret、成功 URL 和取消 URL 设置为对应环境的值。JSON 配置只保存环境变量名称,不保存外部服务 URL 或密钥。
+关闭脚本保留 PostgreSQL/Redis 数据卷,下一次启动仍可继续使用已有控制面数据。默认 `AIGW_STRIPE_ENABLED=false`,预付余额、扣费账本和人工调账仍可使用,但 Checkout、Customer Portal、退款和 Stripe 对账关闭。需要测试 Stripe 时,把 `.env.debug` 中的 restricted key、Webhook signing secret、成功 URL 和取消 URL 设置为对应环境的值,再显式设置 `AIGW_STRIPE_ENABLED=true`。JSON 配置只保存环境变量名称,不保存外部服务 URL 或密钥。
源码未变化时可跳过镜像构建以快速重启:`AIGW_DEBUG_SKIP_BUILD=1 ./scripts/start-debug.sh`。默认构建使用 Docker host network;特殊环境可以通过 `AIGW_DOCKER_BUILD_NETWORK=default` 覆盖。
-然后打开 `http://127.0.0.1:8081/admin/`,本地邮件在 `http://127.0.0.1:8025/` 查看;健康检查在 `http://127.0.0.1:9090/readyz`。Stripe CLI Webhook 转发到 `http://127.0.0.1:8082/billing/stripe/webhook`。启用注册时,新账号必须通过一次性邮件链接验证;团队成员由管理员邀请并自行设置密码。平台管理员也可以从权限为 `0600` 的环境文件读取 `AIGW_ADMIN_TOKEN`,将其作为 bootstrap/break-glass 凭证。日常操作使用邮箱/密码、TOTP 或 Passkey,服务端创建可逐设备撤销的数据库会话,所有写请求需要 CSRF token。第一套资源的创建顺序是:Tenant → Project → API key → Provider → Model route。客户 API Key 明文只在创建成功时返回一次;团队成员使用自己的账号,不共享管理员令牌。
+然后打开 `http://127.0.0.1:8081/admin/`,本地邮件在 `http://127.0.0.1:8025/` 查看;健康检查在 `http://127.0.0.1:9090/readyz`。开发者控制台的调用示例使用 `AIGW_INFERENCE_PUBLIC_URL` 作为网关地址,分离 listener 时不要把它误配成管理地址。Stripe CLI Webhook 转发到 `http://127.0.0.1:8082/billing/stripe/webhook`。启用注册时,新账号必须通过一次性邮件链接验证;团队成员由管理员邀请并自行设置密码。平台管理员也可以从权限为 `0600` 的环境文件读取 `AIGW_ADMIN_TOKEN`,将其作为 bootstrap/break-glass 凭证。日常操作使用邮箱/密码、TOTP 或 Passkey,服务端创建可逐设备撤销的数据库会话,所有写请求需要 CSRF token。第一套资源的创建顺序是:Tenant → Project → API key → Provider → Model route。客户 API Key 明文只在创建成功时返回一次;团队成员使用自己的账号,不共享管理员令牌。
+
+账号邮件先在 PostgreSQL outbox 中加密持久化,再由后台 worker 发送;SMTP 临时不可用不会回滚注册、邀请或重置请求。普通失败指数退避,10 次后进入 dead-letter。生产环境把 `AIGW_PUBLIC_URL` 设置为 HTTPS 控制台 URL,把 `AIGW_SMTP_ADDRESS`、`AIGW_SMTP_FROM_ADDRESS`、`AIGW_SMTP_USERNAME`、`AIGW_SMTP_PASSWORD` 和 `AIGW_MAIL_FEEDBACK_SECRET` 通过密钥管理服务注入,并将 `admin.mail.tls_mode` 改为 `starttls` 或 `tls`。邮件供应商的 bounce/complaint 事件应由边缘适配器规范化后签名发送到 `/mail/feedback`;永久退信和投诉地址会进入抑制表。worker 会按租户保存的阈值、按日幂等发送低余额通知,并发送异常消费通知;未配置租户继续使用 `admin.mail.low_balance_micros` 的全局默认值。域名 DNS 仍必须在邮件供应商处配置 SPF、DKIM 和 DMARC,这不是应用代码可以代替的步骤。WebAuthn 的 `AIGW_WEBAUTHN_RP_ID` 必须是控制台有效域名,`AIGW_WEBAUTHN_ORIGINS` 是逗号分隔的 HTTPS origin。
+
+租户登录后的 Quickstart 首屏会显示充值、密钥、可用模型和首次成功请求四步状态;模型目录按协议、输入模态和开发者筛选,并可按发布时间、名称、输入/输出价格和上下文长度排序,再生成使用 `AIGW_INFERENCE_PUBLIC_URL` 的 cURL、Python 和 Node.js 示例。新工作区可以在 Quickstart 直接为默认项目和当前模型创建 starter key,明文仍只显示一次,同时自动放入当前页面内存中的 Playground。连接面板集中列出 OpenAI/Anthropic SDK Base URL、Chat Completions、Responses、Messages、Models 端点,并可复制不含真实密钥的环境变量模板。每个模型都有客户安全的详情视图,展示协议、模态、上下文、最大输出、能力、生命周期和别名,并按当前价格版本实时估算输入、输出、缓存读取和缓存写入成本;逐供应商运行状态只暴露名称、协议、近期可用率、响应头延迟、样本量和熔断恢复时间,不暴露地址、凭证或上游模型。完全熔断的模型不能从快捷入口发起请求。API Playground 会直接从浏览器调用该推理地址,使用当前客户 API Key 经过完整鉴权、路由、余额冻结/结算和 Usage 链路;它只把 Key 保留在当前页面内存,刷新或退出立即清除。失败时页面保留结构化响应和请求 ID,并把权限、余额、模型、限流或上游错误引导到对应控制台页面。分离 listener 时推理服务只允许 `AIGW_PUBLIC_URL` 的精确 Origin,并且不带管理 Cookie。租户可把目录内可用模型保存为默认模型和不同的 fallback 模型,Quickstart 会立即采用该默认值;billing 成员可以单独启停低余额邮件并设置阈值。
-账号邮件先在 PostgreSQL outbox 中加密持久化,再由后台 worker 发送;SMTP 临时不可用不会回滚注册、邀请或重置请求。普通失败指数退避,10 次后进入 dead-letter。生产环境把 `AIGW_PUBLIC_URL` 设置为 HTTPS 控制台 URL,把 `AIGW_SMTP_ADDRESS`、`AIGW_SMTP_FROM_ADDRESS`、`AIGW_SMTP_USERNAME`、`AIGW_SMTP_PASSWORD` 和 `AIGW_MAIL_FEEDBACK_SECRET` 通过密钥管理服务注入,并将 `admin.mail.tls_mode` 改为 `starttls` 或 `tls`。邮件供应商的 bounce/complaint 事件应由边缘适配器规范化后签名发送到 `/mail/feedback`;永久退信和投诉地址会进入抑制表。worker 会按日幂等发送低余额和异常消费通知。域名 DNS 仍必须在邮件供应商处配置 SPF、DKIM 和 DMARC,这不是应用代码可以代替的步骤。WebAuthn 的 `AIGW_WEBAUTHN_RP_ID` 必须是控制台有效域名,`AIGW_WEBAUTHN_ORIGINS` 是逗号分隔的 HTTPS origin。
+API keys 页面会展示每个 Key 当月已结算费用、待结算冻结、请求数、月度上限、剩余额度、过期时间和最后使用时间;月度统计直接读取 Usage Ledger 与 billing reservation,不在浏览器侧计算。
-控制台角色分为:`platform_admin`、`platform_viewer`、`tenant_admin`、`tenant_billing`、`tenant_developer`、`tenant_viewer`。租户角色的查询条件在服务端下推到 PostgreSQL,不能读取其他租户的项目、密钥、余额、Usage 或审计事件;供应商凭证和路由管理只对平台角色开放。
+控制台角色分为:`platform_admin`、`platform_viewer`、`tenant_admin`、`tenant_billing`、`tenant_developer`、`tenant_viewer`。租户角色的查询条件在服务端下推到 PostgreSQL,不能读取其他租户的项目、密钥、余额、Usage 或审计事件;供应商凭证和路由管理只对平台角色开放。默认模型与财务告警使用独立写权限:developer 不能修改低余额阈值,billing 成员不能修改 API 默认模型。
## Usage、配额与限流
-每个完成上游尝试的请求都会按 `request_id` 幂等写入 `usage_events`,并更新 `usage_monthly_rollups`。Usage 持久化独立于预付费冻结记录,因此关闭计费也不会关闭用量账本。Admin WebUI 提供本月汇总、单请求状态、模型、token、成本、未收金额和延迟查询。
+每个完成上游尝试的请求都会按 `request_id` 幂等写入 `usage_events`,并更新 `usage_monthly_rollups`。Usage 持久化独立于预付费冻结记录,因此关闭计费也不会关闭用量账本。Admin WebUI 提供本月汇总、单请求状态、模型、token、成本、未收金额和延迟查询;同一时间、项目、API Key、模型、供应商 slug、协议、流式状态、错误类型与成功状态过滤会下推到模型成本排行和供应商性能聚合,展示本期/上期费用变化、成功率、缓存命中、P95 延迟和缺失 usage 请求。每条请求可以打开详情,查看完整 request ID、项目与 Key、路由上游、协议、流式状态、重试、吞吐量、缓存 token 和结算状态,并复制不含 prompt、响应正文或客户密钥的诊断 JSON。
Limits 页面按项目配置:
@@ -147,9 +188,9 @@ Redis 可用时,RPM/TPM/并发通过 Lua 原子执行并在多实例间共享
## 余额与 Stripe 充值
-模型价格在后台按“币种单位 / 100 万 token”配置,数据库使用 `amount_micros` 固定精度整数保存金额。Stripe 不直接为推理请求结账,只向 PostgreSQL 预付钱包充值;推理请求先按请求体字节数和 `max_tokens`/`max_completion_tokens` 保守冻结余额,成功响应按可信 usage 扣款,失败请求释放冻结。`request_id` 是用量与扣费幂等键,余额、冻结和不可变 ledger 都在同一个 PG 事务中更新。
+模型价格在后台按“币种单位 / 100 万 token”配置,数据库使用 `amount_micros` 固定精度整数保存金额。Stripe 不直接为推理请求结账,只向 PostgreSQL 预付钱包充值;推理请求先按请求体字节数和 `max_tokens`/`max_completion_tokens` 保守冻结余额,成功响应按可信 usage 扣款,失败请求释放冻结。计量层把 OpenAI 总 input 中的 `cached_tokens`/cache-write 子集归一化为互斥的非缓存输入、缓存读取和缓存写入桶,Anthropic 已独立报告的缓存字段则保持不变,避免按输入价和缓存价重复扣费。`request_id` 是用量与扣费幂等键,余额、冻结和不可变 ledger 都在同一个 PG 事务中更新。
-Stripe 使用托管 Checkout,服务端不会接触卡号,也没有硬编码支付方式;支付方式由 Stripe Dashboard 动态配置。充值只在签名校验通过的 Webhook 确认 `payment_status=paid` 后入账,成功跳转页不会直接修改余额。
+Stripe 使用托管 Checkout,服务端不会接触卡号,也没有硬编码支付方式;支付方式由 Stripe Dashboard 动态配置。租户可在 Billing 页面保存开票名称、邮箱和地址,资料用稳定幂等键创建或更新 Stripe Customer;Stripe 暂时不可用时本地资料标记为待修复,下一次充值、保存支付方式或打开客户门户会再次同步。本地不保存税号,启用且确认 Stripe Tax 注册后由 Checkout/Customer Portal 托管税号。手动充值只在签名校验通过的 Webhook 确认 `payment_status=paid` 后入账,成功跳转页不会直接修改余额。自动充值先通过 Checkout Setup Session 保存支付方式;余额低于客户阈值时,后台 worker 用订单 ID 作为幂等键创建并确认 off-session PaymentIntent。同步结果、Webhook 重放和周期对账都只能生成一笔钱包入账;需要客户认证或支付方式失效时会暂停自动充值,不会循环扣款。
配置 Stripe 测试环境:
@@ -168,12 +209,28 @@ Webhook 至少订阅:
- `checkout.session.async_payment_succeeded`
- `checkout.session.async_payment_failed`
- `checkout.session.expired`
-
-网关 restricted key 的最小权限按实际启用功能配置:Checkout Sessions Write(充值与对账读取)、Customer Portal Write、Customers Write、Charges and Refunds Write、Payment Intents Read 和 Invoices Read。不要给主网关 Debugging Tools 权限;Stripe CLI 使用独立的测试 key。Dashboard 保存权限时可能要求账户持有人完成二次验证。
+- `payment_intent.succeeded`
+- `payment_intent.payment_failed`
+- `payment_intent.canceled`
+- `charge.succeeded`
+- `charge.updated`
+- `charge.refunded`
+- `refund.created`
+- `refund.updated`
+- `refund.failed`
+- `charge.dispute.created`
+- `charge.dispute.updated`
+- `charge.dispute.closed`
+- `invoice.created`
+- `invoice.finalized`
+- `invoice.paid`
+- `invoice.payment_failed`
+
+网关 restricted key 的最小权限按实际启用功能配置:Checkout Sessions Write、Customer Portal Sessions Write、Setup Intents Read、Payment Intents Write、Refunds Write,以及 Customers、Charges、Disputes、Invoices 的 Read;如果 Dashboard 将 Checkout 自动创建 Customer 归入 Customers 写权限,再增加 Customers Write。不要给主网关 Debugging Tools 权限;Stripe CLI 使用独立的测试 key。Dashboard 保存权限时可能要求账户持有人完成二次验证。修改权限后必须在测试模式重新跑一次手动充值、保存支付方式、自动充值、客户门户、退款和对账。
后台已覆盖 Checkout 重试、客户门户、退款队列、争议/发票/收据记录、失败重试、周期性 Stripe 对账和 CSV 财务导出。Stripe 已支付但本地 pending 的订单会自动补账且传播错误不会被忽略;Stripe 中不存在的未入账订单只能通过 RBAC/审计保护的 resolution 作废,已入账孤立充值只能用等额负向账本冲销,原记录不会删除或改写。退款与争议会先冻结/扣除本地余额,余额不足进入 `uncollected_micros`,不会静默丢账。当前没有默认启用 Stripe Tax,因为是否有有效税务注册不能由代码推断;确认注册和 canonical product tax code 后再显式打开。生产环境应把 Stripe restricted key 和 Webhook signing secret 放入云平台的密钥管理服务,并限制密钥权限和来源 IP,不要放进镜像或仓库。
-模型目录支持输入/输出模态、上下文窗口、最大输出、能力集合、生命周期、弃用替代模型、区域和租户/API key allowlist;价格在 `model_price_versions` 中按生效时间版本化。推理请求会在路由前执行区域、生命周期、能力和 allowlist 校验,旧 alias 只映射到 canonical model ID。
+模型目录支持输入/输出模态、上下文窗口、最大输出、能力集合、生命周期、弃用替代模型、区域和租户/API key allowlist;价格在 `model_price_versions` 中按生效时间版本化。每个客户 API Key 还可以独立设置模型白名单、月度消费上限和过期时间:限制随 PostgreSQL generation 热加载到鉴权快照,模型检查发生在路由前,金额上限在钱包行锁事务内和待结算冻结一起检查。旧 alias 只映射到 canonical model ID。
静态模式的上游 Base URL 与 API Key 分别使用 `base_url_env` 和 `api_key_env`;控制面、Redis、Stripe、SMTP、WebAuthn 和监听地址同样只通过环境变量或密钥管理服务注入。严格 JSON 解析会拒绝旧的 `address`、`base_url`、`success_url` 和 `cancel_url` 字面量字段,避免环境隔离被配置文件绕过。版本化配置只保存 `*_env` 名称,不保存外部服务密钥或部署域名。
@@ -196,6 +253,9 @@ go test ./...
go test -race ./...
go vet ./...
CGO_ENABLED=0 go build -buildvcs=false ./cmd/...
+
+# 在容器网络内运行真实 PostgreSQL 集成测试
+docker compose --env-file .env.debug --profile test run --rm integration-test
```
设置 `AIGW_TEST_DATABASE_URL` 后,测试会强制执行 PostgreSQL Webhook/结算/迁移升级用例。CI 还构建容器镜像、生成 SPDX SBOM、以 Trivy 阻断 HIGH/CRITICAL 漏洞,并在 `v*` 标签发布时使用 OIDC keyless Cosign 签名。负载与 Redis 降级演练分别使用 `scripts/load-smoke.sh` 和 `scripts/redis-fault-drill.sh`;备份恢复演练使用 `scripts/backup-postgres.sh` 与 `scripts/restore-drill.sh`。
diff --git a/cmd/aigw/main.go b/cmd/aigw/main.go
index 108a849..3ac388a 100644
--- a/cmd/aigw/main.go
+++ b/cmd/aigw/main.go
@@ -23,6 +23,7 @@ import (
"aigw/internal/mailer"
"aigw/internal/operations"
"aigw/internal/provider"
+ "aigw/internal/providerhealth"
"aigw/internal/routing"
"aigw/internal/telemetry"
@@ -178,12 +179,13 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error {
if billingService != nil {
billingMeter = billingService
}
+ routeHealth := providerhealth.New(providerhealth.Options{})
inferenceAPI := httpapi.New(httpapi.Options{
Authenticator: authenticator,
Catalog: modelCatalog,
- Router: routing.New(modelCatalog),
- Forwarder: provider.New(cfg.UpstreamHTTP, metrics),
+ Router: routing.New(modelCatalog, routeHealth),
+ Forwarder: provider.New(cfg.UpstreamHTTP, metrics, routeHealth),
UsageSink: usageSink,
BillingMeter: billingMeter,
Limiter: requestLimiter,
@@ -193,6 +195,7 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error {
MaxBodyBytes: cfg.Server.MaxBodyBytes,
ExposeMetrics: cfg.Observability.ExposeMetrics,
DeploymentRegion: cfg.Server.DeploymentRegion,
+ BrowserOrigin: cfg.Admin.PublicURL,
})
adminHandler := http.Handler(nil)
if cfg.Admin.Enabled {
@@ -201,6 +204,10 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error {
Logger: logger, Prefix: cfg.Admin.BasePath, RegistrationEnabled: cfg.Admin.RegistrationEnabled,
SessionTTL: time.Duration(cfg.Admin.SessionTTLHours) * time.Hour, Currency: cfg.Billing.Currency,
PublicURL: cfg.Admin.PublicURL, WebAuthn: webAuthn, MailEnabled: cfg.Admin.Mail.Enabled,
+ InferencePublicURL: cfg.Admin.InferencePublicURL,
+ DefaultLowBalanceMicros: cfg.Admin.Mail.LowBalanceMicros,
+ Catalog: modelCatalog,
+ ProviderHealth: routeHealth,
}).Handler()
}
operationHandler := operations.Handler{Store: store, Manager: manager, Billing: billingService, Metrics: metrics,
diff --git a/config.control.example.json b/config.control.example.json
index 58ef2e7..26de205 100644
--- a/config.control.example.json
+++ b/config.control.example.json
@@ -37,6 +37,7 @@
"audit_retention_days": 2555,
"security_retention_days": 30,
"public_url_env": "AIGW_PUBLIC_URL",
+ "inference_public_url_env": "AIGW_INFERENCE_PUBLIC_URL",
"mail": {
"enabled": true,
"from_name": "AIGW",
@@ -66,7 +67,7 @@
"max_top_up_minor": 1000000,
"settlement_spool_path_env": "AIGW_SETTLEMENT_SPOOL_PATH",
"stripe": {
- "enabled": true,
+ "enabled_env": "AIGW_STRIPE_ENABLED",
"api_key_env": "AIGW_STRIPE_API_KEY",
"webhook_secret_env": "AIGW_STRIPE_WEBHOOK_SECRET",
"success_url_env": "AIGW_STRIPE_SUCCESS_URL",
diff --git a/config.example.json b/config.example.json
index 8ac2d0c..a6a5dc1 100644
--- a/config.example.json
+++ b/config.example.json
@@ -23,13 +23,17 @@
"providers": [
{
"id": "openai-primary",
+ "slug": "openai-primary",
"protocol": "openai",
+ "wire_api": "chat_completions",
"base_url_env": "OPENAI_BASE_URL",
"api_key_env": "OPENAI_API_KEY"
},
{
"id": "anthropic-primary",
+ "slug": "anthropic-primary",
"protocol": "anthropic",
+ "wire_api": "messages",
"base_url_env": "ANTHROPIC_BASE_URL",
"api_key_env": "ANTHROPIC_API_KEY"
}
diff --git a/config.local.json b/config.local.json
index 74a324f..f798b2c 100644
--- a/config.local.json
+++ b/config.local.json
@@ -19,13 +19,17 @@
"providers": [
{
"id": "local-openai",
+ "slug": "local-openai",
"protocol": "openai",
+ "wire_api": "chat_completions",
"base_url_env": "MOCK_UPSTREAM_BASE_URL",
"api_key_env": "MOCK_UPSTREAM_KEY"
},
{
"id": "local-anthropic",
+ "slug": "local-anthropic",
"protocol": "anthropic",
+ "wire_api": "messages",
"base_url_env": "MOCK_UPSTREAM_BASE_URL",
"api_key_env": "MOCK_UPSTREAM_KEY"
}
diff --git a/config.responses.example.json b/config.responses.example.json
new file mode 100644
index 0000000..af1611b
--- /dev/null
+++ b/config.responses.example.json
@@ -0,0 +1,47 @@
+{
+ "server": {
+ "address_env": "AIGW_SERVER_ADDRESS",
+ "max_body_bytes": 16777216,
+ "read_header_timeout_seconds": 10,
+ "idle_timeout_seconds": 120,
+ "shutdown_timeout_seconds": 20
+ },
+ "auth": {
+ "keys_env": "AIGW_API_KEYS",
+ "allow_anonymous": false
+ },
+ "upstream_http": {
+ "max_idle_connections": 4096,
+ "max_idle_connections_per_host": 1024,
+ "idle_connection_timeout_seconds": 90,
+ "response_header_timeout_seconds": 60
+ },
+ "observability": {
+ "usage_buffer": 8192,
+ "expose_metrics": true
+ },
+ "providers": [
+ {
+ "id": "openai-responses",
+ "slug": "openai-responses",
+ "protocol": "openai",
+ "wire_api": "responses",
+ "base_url_env": "OPENAI_BASE_URL",
+ "api_key_env": "OPENAI_API_KEY"
+ }
+ ],
+ "models": [
+ {
+ "id": "openai/gpt-5.5",
+ "owned_by": "openai",
+ "routes": [
+ {
+ "provider": "openai-responses",
+ "upstream_model": "gpt-5.5",
+ "priority": 0,
+ "weight": 100
+ }
+ ]
+ }
+ ]
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 8b6b2f1..cc7ecec 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -66,16 +66,18 @@ services:
AIGW_CREDENTIAL_PREVIOUS_KEYS: ${AIGW_CREDENTIAL_PREVIOUS_KEYS:-}
AIGW_ADMIN_TOKEN: ${AIGW_ADMIN_TOKEN:?set AIGW_ADMIN_TOKEN}
AIGW_PUBLIC_URL: ${AIGW_PUBLIC_URL:?set AIGW_PUBLIC_URL}
+ AIGW_INFERENCE_PUBLIC_URL: ${AIGW_INFERENCE_PUBLIC_URL:?set AIGW_INFERENCE_PUBLIC_URL}
AIGW_WEBAUTHN_RP_ID: ${AIGW_WEBAUTHN_RP_ID:?set AIGW_WEBAUTHN_RP_ID}
AIGW_WEBAUTHN_ORIGINS: ${AIGW_WEBAUTHN_ORIGINS:?set AIGW_WEBAUTHN_ORIGINS}
AIGW_SMTP_FROM_ADDRESS: ${AIGW_SMTP_FROM_ADDRESS:?set AIGW_SMTP_FROM_ADDRESS}
AIGW_SMTP_ADDRESS: ${AIGW_SMTP_ADDRESS_DOCKER:?set AIGW_SMTP_ADDRESS_DOCKER}
AIGW_SMTP_USERNAME: ${AIGW_SMTP_USERNAME:-}
AIGW_SMTP_PASSWORD: ${AIGW_SMTP_PASSWORD:-}
- AIGW_STRIPE_API_KEY: ${AIGW_STRIPE_API_KEY:?set AIGW_STRIPE_API_KEY}
- AIGW_STRIPE_WEBHOOK_SECRET: ${AIGW_STRIPE_WEBHOOK_SECRET:?set AIGW_STRIPE_WEBHOOK_SECRET}
- AIGW_STRIPE_SUCCESS_URL: ${AIGW_STRIPE_SUCCESS_URL:?set AIGW_STRIPE_SUCCESS_URL}
- AIGW_STRIPE_CANCEL_URL: ${AIGW_STRIPE_CANCEL_URL:?set AIGW_STRIPE_CANCEL_URL}
+ AIGW_STRIPE_ENABLED: ${AIGW_STRIPE_ENABLED:-false}
+ AIGW_STRIPE_API_KEY: ${AIGW_STRIPE_API_KEY:-}
+ AIGW_STRIPE_WEBHOOK_SECRET: ${AIGW_STRIPE_WEBHOOK_SECRET:-}
+ AIGW_STRIPE_SUCCESS_URL: ${AIGW_STRIPE_SUCCESS_URL:-}
+ AIGW_STRIPE_CANCEL_URL: ${AIGW_STRIPE_CANCEL_URL:-}
AIGW_STRIPE_PORTAL_RETURN_URL: ${AIGW_STRIPE_PORTAL_RETURN_URL:-http://localhost:8081/admin/?billing=portal}
AIGW_STRIPE_AUTOMATIC_TAX_ENABLED: ${AIGW_STRIPE_AUTOMATIC_TAX_ENABLED:-false}
AIGW_STRIPE_TAX_REGISTRATION_CONFIRMED: ${AIGW_STRIPE_TAX_REGISTRATION_CONFIRMED:-false}
@@ -91,7 +93,26 @@ services:
retries: 20
start_period: 5s
+ integration-test:
+ image: aigw-test:local
+ build:
+ context: .
+ target: test
+ profiles: ["test"]
+ working_dir: /src
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ AIGW_TEST_DATABASE_URL: ${AIGW_DATABASE_URL_DOCKER:?set AIGW_DATABASE_URL_DOCKER}
+ GOCACHE: /go-build-cache
+ volumes:
+ - .:/src:ro
+ - aigw-go-build:/go-build-cache
+ command: ["go", "test", "-p=1", "./internal/controlplane", "./internal/billing", "-count=1"]
+
volumes:
aigw-postgres:
aigw-redis:
aigw-settlements:
+ aigw-go-build:
diff --git a/docs/architecture.md b/docs/architecture.md
index c17c65e..f8e617f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -29,35 +29,35 @@ flowchart LR
## 热路径
1. 入口生成不可预测的请求 ID,并通过 Bearer 或 `x-api-key` 解析客户身份。
-2. 身份包含 `key_id`、`tenant_id`、`project_id` 和 scopes;控制面模式使用 PostgreSQL 摘要快照,代理只依赖 `Authenticator` 接口。
+2. 身份包含 `key_id`、`tenant_id`、`project_id`、scopes、密钥模型白名单、月度上限和过期时间;控制面模式使用 PostgreSQL 摘要快照,代理只依赖 `Authenticator` 接口。
3. 请求体在配置上限内读取一次,以提取公开模型名并支持故障转移时重放。
4. 项目策略从 PG 热更新快照读取。Redis Lua 原子占用 RPM、估算 TPM 和并发额度;Redis 不可用时退回本机窗口。
-5. Router 按协议过滤路由,先按优先级分组,再在同级内按权重选择首选上游。
-6. 计费开启时,请求进入上游前在 PostgreSQL 原子检查月度消费、冻结保守估算额度;余额不足返回 402,月度额度耗尽返回 429。
-7. Provider Adapter 重写上游模型名和凭证,使用进程级共享 Transport 发送请求。上游返回成功头后即锁定路由;SSE 逐块 flush。
+5. Router 按协议过滤路由并跳过仍处于冷却期的 route,再按优先级分组、在同级内按权重选择首选上游。请求使用 `model:provider-slug` 时,基础模型先经过租户/API Key 白名单校验,然后只保留该公开 slug 的兼容 route,绝不跨供应商回退;全部匹配 route 熔断时直接返回可重试的 503。
+6. 计费开启时,请求进入上游前在锁定钱包的 PostgreSQL 事务中同时检查项目月度额度、API Key 月度额度和未结冻结,再冻结保守估算额度;余额不足返回 402,任一月度额度耗尽返回 429。
+7. Provider Adapter 重写上游模型名和凭证,使用进程级共享 Transport 发送请求。每次上游尝试记录响应头延迟与可重试失败;连续 3 次连接失败或 429/502/503/504 后熔断该模型/供应商 route 30 秒。上游返回成功头后即锁定路由;SSE 逐块 flush。
8. 请求结束后释放并发 lease,并用 `request_id` 幂等持久化 UsageEvent、更新月度汇总、按实际 token 结算;结构化日志仅是异步副本。
## 已固定的扩展边界
| 边界 | 当前实现 | 下一阶段替换 |
| --- | --- | --- |
-| 客户身份 | PostgreSQL 快照、内存 SHA-256 索引 | SSO/OIDC、SCIM、模型 allowlist |
+| 客户身份 | PostgreSQL 快照、内存 SHA-256 索引、密钥过期/模型白名单/月度上限 | IP/CIDR 策略、短期服务身份 |
| 控制台权限 | 邮箱验证/邀请/重置、登录限流、设备会话、TOTP/恢复码、Passkey、CSRF、六角色 RBAC、租户 SQL scope、审计日志 | SSO/OIDC、SCIM、组织级 MFA 策略、自定义角色、审批流 |
-| 权限 | `Principal.Scopes` 中的 `inference` + 控制台 RBAC | ABAC、IP 与模型策略 |
-| 模型目录 | PostgreSQL 快照 + 可选 Redis generation 广播 + PG 轮询兜底 | 版本化控制面、热更新、灰度发布 |
-| 路由 | priority + weighted selection + failover | 健康评分、延迟 EWMA、成本/质量策略、熔断 |
+| 权限 | `Principal.Scopes` 中的 `inference`、API Key 模型限制 + 控制台 RBAC | ABAC、IP 与条件策略 |
+| 模型目录 | PostgreSQL 快照 + 可选 Redis generation 广播 + PG 轮询兜底;租户安全目录 API、供应商运行状态与 Quickstart | 公开 SEO 目录、状态历史、灰度发布 |
+| 路由 | priority + weighted selection + failover + `model:provider-slug` 固定供应商 + 真实流量滑动窗口 + 响应头延迟 EWMA + 熔断 | 主动探测、TTFT/吞吐量、成本/质量策略、跨实例健康聚合 |
| 计量 | PostgreSQL UsageEvent + 月度汇总 + 异步日志副本 | 分区表、持久消息流、供应商账单对账 |
-| 计费 | 预付余额、冻结/结算、不可变流水、Stripe Checkout | 价格版本、退款/争议、信用额度、Metronome 企业合同 |
+| 计费 | 版本价格、预付余额、冻结/结算、不可变流水、Stripe 手动/自动充值、退款/争议/对账 | 信用额度、合同价、Metronome 企业合同 |
| 限流 | Redis Lua 全局 RPM/估算 TPM/并发,故障时本机降级;PG 月度消费配额 | 滑动窗口、层级策略、边缘 token bucket |
-| 协议 | 同协议透传 | 规范化 IR + OpenAI/Anthropic/Google 双向转换 |
+| 协议 | Chat Completions、Responses、Anthropic Messages 同 wire API 透传 | 规范化 IR + OpenAI/Anthropic/Google 双向转换 |
## 计费数据原则
真实计费不依赖请求日志。当前以 `request_id` 作为 reservation、usage 和扣费幂等键,价格快照随冻结记录保存,账本流水不可变。余额使用百万分之一币种单位,所有变更在锁定 tenant wallet 的 PostgreSQL 事务中完成。
-Stripe 充值使用 Checkout Session:本地先创建 top-up order,Stripe 请求使用 order ID 作为幂等键;Webhook 验证签名后再次核对 event ID、order ID、session ID、金额和币种。浏览器成功跳转不具有入账权威性。
+Stripe 手动充值使用 Checkout Session:本地先创建 top-up order,Stripe 请求使用 order ID 作为幂等键;Webhook 验证签名后再次核对 event ID、order ID、session ID、金额和币种。自动充值使用 Checkout Setup Session 保存支付方式,低余额 worker 再创建 off-session PaymentIntent;同一订单的同步成功、Webhook 与对账共享幂等账本来源。浏览器成功跳转不具有入账权威性。
-流式请求的 usage 可能只在最后事件出现。当前 observer 会在线解析 OpenAI/Anthropic SSE 中的 usage;如果上游不返回 usage,事件中的 token 为零。接入商业扣费前,应为每种上游建立有测试的 usage normalizer,并使用上游账单进行日对账。
+流式请求的 usage 可能只在最后事件出现。当前 observer 会在线解析 Chat Completions、Responses 和 Anthropic SSE 中的 usage,并把 OpenAI details 中属于总 input 子集的缓存 token 拆成互斥计费桶;Anthropic 独立缓存字段不做减法。如果计费上游不返回 usage,请求会进入 `metering_failed` 并保持余额冻结,而不是按零费用结算。每种上游仍需使用供应商账单做日对账。
## 扩容方式
@@ -67,6 +67,60 @@ Stripe 充值使用 Checkout Session:本地先创建 top-up order,Stripe 请
- 请求体默认最多 16 MiB,响应仅保留最多 64 KiB 用于非流式 usage 提取;正文直接传输。
- 余额冻结和结算当前各需要一次 PostgreSQL 事务,以正确性优先。高吞吐阶段可把计费拆成独立服务并做账户分片,但不能用最终一致缓存替代权威账本事务。
+## 开发者上手路径
+
+租户控制台的 Quickstart 首屏把充值、API key、可用模型和首次成功请求显示为
+可追踪的四步状态。模型目录通过 `GET /admin/api/developer/models` 返回公开元数据和
+当前生效价格,只保留该租户可用的模型与 wire API;它不会返回供应商地址、上游模型名、
+路由权重或 allowlist。控制台可按开发者、协议和输入模态过滤,并按发布时间、价格或上下文
+排序。`GET /admin/api/developer/config` 返回由
+`AIGW_INFERENCE_PUBLIC_URL` 注入的推理地址和协议端点,控制台据此生成 cURL、Python
+和 Node.js 示例。新建客户密钥仍只在创建响应和一次性对话框中出现,代码示例默认使用
+`$AIGW_API_KEY`,不会把明文密钥写入本地存储或 HTML。
+
+同一 Quickstart 页面提供 API Playground。它使用所选客户 Key 直接调用配置中的公开推理
+地址,因此请求仍经过正式鉴权、模型限制、路由、余额冻结、结算与 Usage Ledger。Key 只
+存在于当前页面内存。分离 listener 时,推理服务仅允许从 `AIGW_PUBLIC_URL` 派生出的精确
+Origin,且不接受浏览器 credentials,只向页面暴露 `X-AIGW-Request-ID`。
+
+模型详情由同一份租户安全目录数据渲染,不暴露供应商凭证、内部路由或上游模型名。成本
+估算按当前版本化单价分别计算输入、输出、缓存读取和缓存写入 token;它是请求前预算工具,
+实际扣款仍只认 Usage Ledger。Playground 对 `401/403`、`402`、`404`、`429` 和 `5xx`
+提供不同的恢复入口,同时原样保留结构化错误与 request ID,便于开发者和支持人员定位。
+目录还把当前内存 route 状态按模型投影为 `online`、`degraded` 或 `unavailable`,详情只返回
+供应商公开 slug、显示名、协议、近期可用率、响应头延迟、样本量和熔断恢复时间。Quickstart
+和 Playground 默认使用自动路由,也可以把所选 slug 编入 `model:provider-slug` 来固定供应商。
+`GET /v1/models` 和 Anthropic 模型列表同样只公开 slug 与 wire API,不返回内部 UUID、URL、
+凭证、上游模型名或权重。统计窗口是当前实例
+最近 100 次真实尝试;它不冒充主动健康检查、首 token 延迟、持久状态历史或全局 SLA。
+
+Usage 页不保存 prompt 或响应正文。单请求详情仅把已持久化的身份边界、模型路由、协议、
+重试、延迟、token、缓存和结算字段组成可复制诊断 JSON,因此既能支持工单排障,也不会
+把客户输入扩大为新的控制面敏感数据面。
+
+`GET /admin/api/usage/analytics` 直接聚合 PostgreSQL Usage Ledger,并复用 Usage 页的租户、
+项目、API Key、模型、状态和时间过滤。结果按模型与供应商返回请求量、成功率、token、
+缓存命中、费用、未收金额、缺失 usage 和 P95 延迟,同时用等长前一周期计算费用变化。
+它不在推理热路径执行,也不从浏览器当前加载的有限请求列表推算财务数据。
+
+Quickstart 的 starter key 表单复用正式 `POST /admin/api/keys` 写入链路,为当前租户、所选
+项目和当前模型生成仅含 `inference` scope 的 Key。`api_keys(project_id, tenant_id)` 到
+`projects(id, tenant_id)` 的复合外键保证项目不能跨租户绑定;明文 Key 仍只在创建响应中
+返回一次,随后仅保存在页面内存并填入 Playground。连接面板直接消费
+`GET /admin/api/developer/config`,集中输出两个 SDK Base URL、四个推理/模型端点和不含
+真实凭证的环境变量模板。
+
+密钥表单可设置模型白名单、月度金额上限、到期时间和标签。白名单与密钥摘要在同一个
+PostgreSQL 事务内创建,任何未知模型都会让事务整体回滚。`last_used_at` 在 Usage/结算
+事务内单调更新;过期时间既用于快照过滤,也在每次鉴权时检查,避免长轮询间隔延迟失效。
+密钥列表还通过 key/time 索引从 PostgreSQL 返回本月已结算费用、待结算冻结和请求数。
+
+`tenant_preferences` 保存租户默认模型、不同的 fallback 模型和低余额提醒阈值。
+`GET /admin/api/developer/preferences` 返回当前租户值;两个独立的写接口分别要求
+`developer.preferences.write` 与 `billing.preferences.write`,因此 developer 与 billing 角色
+不能越权修改对方的设置。保存默认/fallback 时服务端会重新验证该模型当前对租户可见、
+未退役且至少存在一条启用路由。邮件扫描直接读取 PostgreSQL 中的租户阈值,不依赖 Redis。
+
## 管理面安全
bootstrap token 只映射为 `platform_admin`,用于首次建号和故障恢复,不是日常用户凭证。租户注册在同一 PostgreSQL 事务内创建租户、默认项目、钱包和待验证的 `tenant_admin` 账号;邮件动作使用仅保存摘要的一次性 token,邮件正文在 outbox 中加密。密码使用 PBKDF2-HMAC-SHA-256 哈希,TOTP secret、Passkey credential 和 WebAuthn challenge 使用 AES-256-GCM 加密。登录创建 HttpOnly、SameSite 会话 Cookie,并为所有写请求校验独立 CSRF Cookie/header;改密、密码重置和撤销成员会立即失效旧会话。平台角色没有 `tenant_id`,租户角色必须绑定一个 tenant。所有管理 API 在 handler 执行前校验 permission,租户过滤在 SQL 查询或资源所有权检查中完成,前端隐藏菜单不承担安全职责。
@@ -76,7 +130,7 @@ bootstrap token 只映射为 `platform_admin`,用于首次建号和故障恢
## 建议的后续顺序
1. 将管理监听端口与公网推理端口分离,并为企业客户接入 OIDC/SAML、SCIM 与组织级强制 MFA 策略。
-2. 增加价格版本、退款/冲正、Stripe dispute 处理和供应商日账单对账。
+2. 增加供应商日账单对账和合同价/信用额度。
3. 为不返回 usage 的上游增加可靠 token 计算器,并监控 `uncollected_micros`。
-4. 把 UsageEvent 做时间分区和归档,增加 CSV 导出与对账作业。
-5. 主动健康检查、熔断、延迟 EWMA 和按成本路由。
+4. 把 UsageEvent 做时间分区和归档,增加定时导出与报告。
+5. 增加主动健康检查、TTFT/吞吐量采样、跨实例状态聚合和按成本/性能路由。
diff --git a/docs/commercial-readiness.md b/docs/commercial-readiness.md
index b78ae2d..6dc9064 100644
--- a/docs/commercial-readiness.md
+++ b/docs/commercial-readiness.md
@@ -7,55 +7,95 @@ commercial feature.
## What works now
-- OpenAI Chat Completions and Anthropic Messages proxying, streaming, routing,
+- OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages proxying, streaming, routing,
retry, authentication, persistent usage, prepaid billing, quotas, rate limits,
concurrent request limits, RBAC, audit logs, and a PostgreSQL-backed console.
-- Stripe-hosted Checkout with signed, idempotent Webhook crediting. The gateway
- never accepts card details and never credits a success redirect.
+- Stripe-hosted manual top-up and payment-method setup, off-session automatic
+ top-up, signed/idempotent Webhook crediting, refund/dispute handling, and
+ reconciliation. The gateway never accepts card details and never credits a
+ success redirect.
+- Tenant billing profiles persist invoice name, email, and postal address and
+ synchronize them to Stripe Customer with a stable idempotency key. Stripe
+ failures preserve the local profile for retry; tax IDs stay in Stripe-hosted
+ Checkout or Customer Portal rather than this database.
- PostgreSQL is the source of truth. Redis accelerates invalidation and shared
counters but is not required for startup, control-plane writes, billing, or
balance correctness.
- Verified self-service registration, invitation acceptance, password reset,
persistent login throttles, per-device session revocation, encrypted email
outbox delivery, TOTP with recovery codes, and WebAuthn Passkeys.
+- Tenant-scoped default/fallback model preferences and RBAC-separated low-balance
+ notification thresholds, persisted in PostgreSQL and applied by Quickstart and
+ the notification worker.
+- Per-key model restrictions, monthly spend caps, expiration, tags, and last-use
+ tracking. Restrictions are enforced by the runtime snapshot and billing
+ transaction, not only rendered by the console. The developer console also has
+ a page-memory API Playground and displays current-month settled spend, pending
+ reservations, request count, remaining cap, and last use for each key.
+- The authenticated model catalog includes a customer-safe detail view, current
+ price-version cost estimates for input/output/cache tokens, copyable model IDs,
+ developer filtering, release/price/context sorting, one-click Playground
+ selection, cURL/Python/Node examples, and actionable diagnostics for
+ authentication, balance, model, rate-limit, and provider errors.
+- The unauthenticated `/admin/models` catalog exposes only globally available
+ models and supports search, protocol/input/developer filters, release/price/context
+ sorting, model details, versioned token prices, aggregate route availability,
+ and a pre-registration cost estimate. Tenant/key allowlists, upstream model IDs,
+ provider IDs, URLs, and routing weights are excluded by a dedicated public type.
+- Quickstart colocates balance state, direct starter-key creation, OpenAI and
+ Anthropic SDK base URLs, copyable REST endpoints, environment configuration,
+ code examples, and the live Playground. A newly created key is scoped to the
+ selected project/model and is kept only in page memory after its one-time reveal.
+- Usage events open into a privacy-safe request diagnostic with the complete
+ request ID, route, retry, protocol, latency, throughput, cache-token, and
+ settlement fields; copied JSON excludes prompts, responses, and secrets.
+- Ledger-backed model cost ranking and provider performance views share the
+ Usage filters and report period-over-period charge change, success rate,
+ cache hit, P95 latency, and missing-usage exposure without sampling browser data.
+- Runtime routes use a per-instance 100-attempt availability window, response-header
+ latency EWMA, and a 3-failure/30-second circuit breaker. The customer model detail
+ compares safe provider runtime fields and prevents launching a model while every
+ route is cooling down.
+- Developers can keep automatic failover or pin a request with
+ `model-id:provider-slug`. Provider slugs are stable public identifiers returned by
+ the safe model catalog and selectable in Quickstart and Playground; pinned
+ requests never fail over to another provider, while billing and key allowlists
+ remain keyed by the canonical base model.
## Customer product gaps
### P0 before a public commercial launch
-- Production transactional-email provider selection, domain authentication,
- bounce/complaint handling, low-balance notifications, and monitoring for the
- durable mail outbox. Local development currently uses Mailpit.
-- A public model catalog and detail page containing provider/developer, release
- and retirement dates, input/output modalities, context and maximum output,
- supported parameters and protocols, regional availability, and versioned price
- dimensions.
-- Tenant and API-key model allowlists, budget alerts, low-balance notifications,
- downloadable invoices/receipts, payment history, refunds/disputes operations,
- and explicit tax handling after registrations are confirmed.
+- Production mail provider DNS authentication (SPF/DKIM/DMARC) and provider-side
+ bounce/complaint wiring remain deployment tasks; the signed feedback endpoint,
+ suppression table, low-balance notifications, retries, and dead-letter mail
+ outbox are implemented. Local development uses Mailpit.
+- Explicit tax treatment after registrations are confirmed still needs legal
+ and product sign-off. Invoice details, hosted invoice/PDF/receipt links,
+ payment history, refunds, disputes, reconciliation, and CSV ledger export are
+ implemented.
- Operational separation of the public inference listener from the management
listener, HTTPS-only cookies behind a trusted proxy, backup/restore drills,
migration rollback policy, secret rotation, and alerting for usage settlement
or Webhook backlogs.
-- A durable settlement retry/outbox and reconciliation worker. Persistence and
- balance settlement currently use bounded synchronous database calls after the
- response; a database timeout is logged but is not queued for retry, so a long
- outage can leave reservations pending or usage unbilled.
+- Enterprise identity integrations (OIDC/SAML/SCIM), custom roles, and approval
+ workflows are not included in the current console; password, invite, session,
+ TOTP, Passkey, RBAC, and audit flows are implemented.
### P1 for ZenMux-like breadth
-- OpenAI Responses, Embeddings, Images, Speech and Transcriptions; Gemini native
+- OpenAI Embeddings, Images, Speech and Transcriptions; Gemini native
APIs; rerank and other media endpoints. The existing protocol field does not
make these APIs implemented.
-- Provider health measurements per model and route: availability, first-token
- latency, throughput, error history, health-aware routing, and customer-visible
- status history.
-- Provider comparison and price ranges, cache/search/image/audio pricing units,
- lifecycle aliases and deprecation notices, searchable filters, release sorting,
- SDK examples, and copyable endpoint snippets.
-- Usage exports, cost attribution, budgets, scheduled reports, organization
- invites, custom roles, OIDC/SAML SSO, SCIM, and support impersonation with
- approval and full audit evidence.
+- Active provider probes, first-token latency, throughput-aware selection,
+ cross-instance health aggregation, and customer-visible status history. Runtime
+ circuit breaking and request-derived route health are implemented; historical
+ success, total latency, cache hit, missing usage, and cost come from the Usage Ledger.
+- Provider price ranges, non-token search/image/audio pricing units, and richer
+ deprecation notices. Provider runtime comparison and release sorting are implemented.
+- Usage exports, scheduled reports, organization invites, custom roles,
+ OIDC/SAML SSO, SCIM, and support impersonation with approval and full audit
+ evidence. Model/provider cost attribution is implemented in the console.
## Environment boundary
@@ -70,11 +110,13 @@ with mode `0600`.
| Redis URLs | `AIGW_REDIS_URL`, `AIGW_REDIS_URL_DOCKER` |
| Provider credential encryption | `AIGW_CREDENTIAL_KEY` |
| Bootstrap administrator | `AIGW_ADMIN_TOKEN` |
+| Stripe integration switch | `AIGW_STRIPE_ENABLED` |
| Stripe application key | `AIGW_STRIPE_API_KEY` |
| Stripe CLI development key | `AIGW_STRIPE_CLI_API_KEY` |
| Stripe Webhook signing secret | `AIGW_STRIPE_WEBHOOK_SECRET` |
-| Stripe result URLs | `AIGW_STRIPE_SUCCESS_URL`, `AIGW_STRIPE_CANCEL_URL` |
+| Stripe result URLs | `AIGW_STRIPE_SUCCESS_URL`, `AIGW_STRIPE_CANCEL_URL`, `AIGW_STRIPE_PORTAL_RETURN_URL` |
| Console public URL | `AIGW_PUBLIC_URL` |
+| Public inference/API URL used by customer examples | `AIGW_INFERENCE_PUBLIC_URL` |
| SMTP endpoint/sender | `AIGW_SMTP_ADDRESS`, `AIGW_SMTP_FROM_ADDRESS` |
| SMTP credentials | `AIGW_SMTP_USERNAME`, `AIGW_SMTP_PASSWORD` |
| WebAuthn RP/origins | `AIGW_WEBAUTHN_RP_ID`, `AIGW_WEBAUTHN_ORIGINS` |
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go
index 9f460e3..ff87ac9 100644
--- a/internal/adminapi/api.go
+++ b/internal/adminapi/api.go
@@ -13,13 +13,18 @@ import (
"log/slog"
"net"
"net/http"
+ "sort"
+ "strconv"
"strings"
"time"
"aigw/internal/adminui"
"aigw/internal/apierror"
"aigw/internal/billing"
+ "aigw/internal/catalog"
"aigw/internal/controlplane"
+ "aigw/internal/domain"
+ "aigw/internal/providerhealth"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/jackc/pgx/v5/pgconn"
@@ -38,6 +43,10 @@ type API struct {
publicURL string
webauthn *webauthn.WebAuthn
mailEnabled bool
+ inferencePublicURL string
+ defaultLowBalance int64
+ catalog *catalog.Catalog
+ health *providerhealth.Tracker
}
type actorKey struct{}
@@ -58,18 +67,22 @@ func (w *auditWriter) Write(body []byte) (int, error) {
}
type Options struct {
- Store *controlplane.Store
- Manager *controlplane.Manager
- Billing *billing.Service
- Token string
- Logger *slog.Logger
- Prefix string
- RegistrationEnabled bool
- SessionTTL time.Duration
- Currency string
- PublicURL string
- WebAuthn *webauthn.WebAuthn
- MailEnabled bool
+ Store *controlplane.Store
+ Manager *controlplane.Manager
+ Billing *billing.Service
+ Token string
+ Logger *slog.Logger
+ Prefix string
+ RegistrationEnabled bool
+ SessionTTL time.Duration
+ Currency string
+ PublicURL string
+ WebAuthn *webauthn.WebAuthn
+ MailEnabled bool
+ InferencePublicURL string
+ DefaultLowBalanceMicros int64
+ Catalog *catalog.Catalog
+ ProviderHealth *providerhealth.Tracker
}
func New(options Options) *API {
@@ -89,7 +102,8 @@ func New(options Options) *API {
return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token),
logger: options.Logger, prefix: prefix, registrationEnabled: options.RegistrationEnabled,
sessionTTL: options.SessionTTL, currency: options.Currency, publicURL: strings.TrimRight(options.PublicURL, "/") + "/",
- webauthn: options.WebAuthn, mailEnabled: options.MailEnabled}
+ webauthn: options.WebAuthn, mailEnabled: options.MailEnabled, inferencePublicURL: strings.TrimRight(options.InferencePublicURL, "/"),
+ defaultLowBalance: options.DefaultLowBalanceMicros, catalog: options.Catalog, health: options.ProviderHealth}
}
func (a *API) Handler() http.Handler {
@@ -103,6 +117,8 @@ func (a *API) Handler() http.Handler {
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
})
mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler()))
+ mux.HandleFunc("GET "+apiPrefix+"/public/models", a.public(a.publicModels))
+ mux.HandleFunc("GET "+apiPrefix+"/public/models/{id...}", a.public(a.publicModel))
mux.HandleFunc("GET "+apiPrefix+"/auth/config", a.public(a.authConfig))
mux.HandleFunc("GET "+apiPrefix+"/auth/session", a.public(a.authSession))
mux.HandleFunc("POST "+apiPrefix+"/auth/register", a.public(a.register))
@@ -131,6 +147,11 @@ func (a *API) Handler() http.Handler {
mux.HandleFunc("POST "+apiPrefix+"/auth/passkeys/{id}/delete", a.withAuth("overview.read", a.deletePasskey))
mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview))
+ mux.HandleFunc("GET "+apiPrefix+"/developer/config", a.withAuth("overview.read", a.developerConfig))
+ mux.HandleFunc("GET "+apiPrefix+"/developer/models", a.withAuth("overview.read", a.developerModels))
+ mux.HandleFunc("GET "+apiPrefix+"/developer/preferences", a.withAuth("preferences.read", a.developerPreferences))
+ mux.HandleFunc("PUT "+apiPrefix+"/developer/preferences", a.withAuth("developer.preferences.write", a.updateDeveloperPreferences))
+ mux.HandleFunc("PUT "+apiPrefix+"/developer/preferences/billing", a.withAuth("billing.preferences.write", a.updateBillingPreferences))
mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants))
mux.HandleFunc("POST "+apiPrefix+"/tenants", a.withAuth("tenants.write", a.createTenant))
mux.HandleFunc("GET "+apiPrefix+"/projects", a.withAuth("projects.read", a.listProjects))
@@ -148,12 +169,17 @@ func (a *API) Handler() http.Handler {
mux.HandleFunc("POST "+apiPrefix+"/reload", a.withAuth("platform.write", a.reload))
if a.billing != nil {
mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/profile", a.withAuth("billing.read", a.getBillingProfile))
+ mux.HandleFunc("PUT "+apiPrefix+"/billing/profile", a.withAuth("billing.topup", a.updateBillingProfile))
mux.HandleFunc("GET "+apiPrefix+"/billing/ledger", a.withAuth("billing.read", a.listBillingLedger))
mux.HandleFunc("GET "+apiPrefix+"/billing/orders", a.withAuth("billing.read", a.listTopUpOrders))
mux.HandleFunc("GET "+apiPrefix+"/billing/orders/{id}", a.withAuth("billing.read", a.getTopUpOrder))
mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance))
mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession))
mux.HandleFunc("POST "+apiPrefix+"/billing/portal-sessions", a.withAuth("billing.topup", a.createPortalSession))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/auto-topup", a.withAuth("billing.read", a.getAutoTopUp))
+ mux.HandleFunc("PUT "+apiPrefix+"/billing/auto-topup", a.withAuth("billing.topup", a.updateAutoTopUp))
+ mux.HandleFunc("POST "+apiPrefix+"/billing/auto-topup/setup-sessions", a.withAuth("billing.topup", a.createAutoTopUpSetupSession))
mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/retry", a.withAuth("billing.topup", a.retryCheckoutSession))
mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/resolve-missing", a.withAuth("billing.adjust", a.resolveMissingTopUp))
mux.HandleFunc("POST "+apiPrefix+"/billing/orders/{id}/reverse-missing-credit", a.withAuth("billing.adjust", a.reverseMissingTopUpCredit))
@@ -166,6 +192,8 @@ func (a *API) Handler() http.Handler {
}
mux.HandleFunc("GET "+apiPrefix+"/usage", a.withAuth("usage.read", a.listUsage))
mux.HandleFunc("GET "+apiPrefix+"/usage/summary", a.withAuth("usage.read", a.usageSummary))
+ mux.HandleFunc("GET "+apiPrefix+"/usage/daily", a.withAuth("usage.read", a.usageDaily))
+ mux.HandleFunc("GET "+apiPrefix+"/usage/analytics", a.withAuth("usage.read", a.usageAnalytics))
mux.HandleFunc("GET "+apiPrefix+"/limits", a.withAuth("limits.read", a.listLimits))
mux.HandleFunc("POST "+apiPrefix+"/limits/{project_id}", a.withAuth("limits.write", a.setLimit))
mux.HandleFunc("GET "+apiPrefix+"/users", a.withAuth("users.read", a.listUsers))
@@ -932,6 +960,228 @@ func (a *API) overview(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
+func (a *API) developerConfig(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, map[string]any{
+ "base_url": a.inferencePublicURL,
+ "endpoints": map[string]string{
+ "chat_completions": "/v1/chat/completions",
+ "responses": "/v1/responses",
+ "messages": "/anthropic/v1/messages",
+ "models": "/v1/models",
+ },
+ })
+}
+
+func (a *API) developerModels(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.ListDeveloperModels(r.Context(), a.actor(r).TenantID)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ if err := a.addDeveloperModelHealth(r.Context(), result); err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) publicModels(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.ListPublicModels(r.Context())
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.addPublicModelHealth(result)
+ w.Header().Set("Cache-Control", "public, max-age=30, stale-while-revalidate=120")
+ writeJSON(w, map[string]any{
+ "data": result,
+ "inference_base_url": a.inferencePublicURL,
+ "registration_enabled": a.registrationEnabled,
+ })
+}
+
+func (a *API) publicModel(w http.ResponseWriter, r *http.Request) {
+ wanted := strings.Trim(strings.TrimSpace(r.PathValue("id")), "/")
+ result, err := a.store.ListPublicModels(r.Context())
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.addPublicModelHealth(result)
+ for _, item := range result {
+ if item.PublicID == wanted {
+ w.Header().Set("Cache-Control", "public, max-age=30, stale-while-revalidate=120")
+ writeJSON(w, item)
+ return
+ }
+ }
+ apierror.Write(w, apierror.Error{Status: http.StatusNotFound, Type: "model_not_found", Message: "Model not found"}, requestID(r))
+}
+
+func (a *API) addPublicModelHealth(models []controlplane.PublicModel) {
+ if a.catalog == nil {
+ return
+ }
+ statusByRoute := make(map[providerhealth.RouteKey]providerhealth.Status)
+ if a.health != nil {
+ for _, item := range a.health.Snapshot() {
+ statusByRoute[providerhealth.RouteKey{ModelID: item.ModelID, ProviderID: item.ProviderID, WireAPI: item.WireAPI}] = item
+ }
+ }
+ for index := range models {
+ model, err := a.catalog.Model(models[index].PublicID)
+ if err != nil {
+ continue
+ }
+ seen := make(map[string]struct{})
+ available := 0
+ for _, route := range model.Routes {
+ wireAPI := route.Provider.EffectiveWireAPI()
+ dedupe := route.Provider.ID + "\x00" + wireAPI
+ if _, exists := seen[dedupe]; exists {
+ continue
+ }
+ seen[dedupe] = struct{}{}
+ status, measured := statusByRoute[providerhealth.RouteKey{ModelID: model.ID, ProviderID: route.Provider.ID, WireAPI: wireAPI}]
+ if !measured || status.State != "open" {
+ available++
+ }
+ }
+ models[index].ProviderCount = len(seen)
+ models[index].AvailableProviderCount = available
+ switch {
+ case len(seen) == 0 || available == 0:
+ models[index].HealthStatus = "unavailable"
+ case available < len(seen):
+ models[index].HealthStatus = "degraded"
+ default:
+ models[index].HealthStatus = "available"
+ }
+ }
+}
+
+func (a *API) addDeveloperModelHealth(ctx context.Context, models []controlplane.DeveloperModel) error {
+ if a.catalog == nil {
+ return nil
+ }
+ providers, err := a.store.ListProviders(ctx)
+ if err != nil {
+ return err
+ }
+ providersByID := make(map[string]controlplane.Provider, len(providers))
+ for _, item := range providers {
+ providersByID[item.ID] = item
+ }
+ statusByRoute := make(map[providerhealth.RouteKey]providerhealth.Status)
+ if a.health != nil {
+ for _, item := range a.health.Snapshot() {
+ statusByRoute[providerhealth.RouteKey{ModelID: item.ModelID, ProviderID: item.ProviderID, WireAPI: item.WireAPI}] = item
+ }
+ }
+ for index := range models {
+ model, err := a.catalog.Model(models[index].PublicID)
+ if err != nil {
+ continue
+ }
+ seen := make(map[string]struct{})
+ items := make([]controlplane.DeveloperProviderHealth, 0, len(model.Routes))
+ available := 0
+ for _, route := range model.Routes {
+ key := providerhealth.RouteKey{ModelID: model.ID, ProviderID: route.Provider.ID, WireAPI: route.Provider.EffectiveWireAPI()}
+ dedupe := key.ProviderID + "\x00" + key.WireAPI
+ if _, exists := seen[dedupe]; exists {
+ continue
+ }
+ seen[dedupe] = struct{}{}
+ status, measured := statusByRoute[key]
+ state := "unknown"
+ if measured {
+ state = status.State
+ }
+ if state != "open" {
+ available++
+ }
+ provider := providersByID[route.Provider.ID]
+ items = append(items, controlplane.DeveloperProviderHealth{Slug: route.Provider.EffectiveSlug(), Name: provider.Name,
+ Protocol: string(route.Provider.Protocol), WireAPI: key.WireAPI, State: state, Attempts: status.Attempts,
+ RecentSamples: status.RecentSamples, AvailabilityPercent: status.AvailabilityPercent,
+ HeaderLatencyEWMA: status.HeaderLatencyEWMA, ConsecutiveFailures: status.ConsecutiveFailures,
+ LastObservedAt: status.LastObservedAt, CircuitOpenUntil: status.CircuitOpenUntil})
+ }
+ sort.Slice(items, func(i, j int) bool {
+ iOpen := items[i].State == "open"
+ jOpen := items[j].State == "open"
+ if iOpen != jOpen {
+ return !iOpen
+ }
+ if items[i].Name != items[j].Name {
+ return items[i].Name < items[j].Name
+ }
+ return items[i].WireAPI < items[j].WireAPI
+ })
+ models[index].Providers = items
+ models[index].ProviderCount = len(items)
+ models[index].AvailableProviderCount = available
+ switch {
+ case len(items) == 0 || available == 0:
+ models[index].HealthStatus = "unavailable"
+ case available < len(items):
+ models[index].HealthStatus = "degraded"
+ default:
+ models[index].HealthStatus = "online"
+ }
+ }
+ return nil
+}
+
+func (a *API) developerPreferences(w http.ResponseWriter, r *http.Request) {
+ result, err := a.store.GetTenantPreferences(r.Context(), a.preferenceTenantID(r, ""), a.defaultLowBalance)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) updateDeveloperPreferences(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.SetDeveloperPreferencesInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ input.TenantID = a.preferenceTenantID(r, input.TenantID)
+ result, err := a.store.SetDeveloperPreferences(r.Context(), input)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) updateBillingPreferences(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.SetBillingPreferencesInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ input.TenantID = a.preferenceTenantID(r, input.TenantID)
+ result, err := a.store.SetBillingPreferences(r.Context(), input, a.defaultLowBalance)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) preferenceTenantID(r *http.Request, requested string) string {
+ actor := a.actor(r)
+ if actor.TenantID != "" {
+ return actor.TenantID
+ }
+ if requested = strings.TrimSpace(requested); requested != "" {
+ return requested
+ }
+ return strings.TrimSpace(r.URL.Query().Get("tenant_id"))
+}
+
func (a *API) listBillingAccounts(w http.ResponseWriter, r *http.Request) {
result, err := a.billing.ListAccounts(r.Context(), a.actor(r).TenantID)
if err != nil {
@@ -941,6 +1191,38 @@ func (a *API) listBillingAccounts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
+func (a *API) getBillingProfile(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.preferenceTenantID(r, "")
+ if tenantID == "" {
+ a.scopeError(w, r)
+ return
+ }
+ result, err := a.billing.GetBillingProfile(r.Context(), tenantID)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) updateBillingProfile(w http.ResponseWriter, r *http.Request) {
+ var input billing.UpdateBillingProfileInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ input.TenantID = a.preferenceTenantID(r, input.TenantID)
+ if input.TenantID == "" {
+ a.scopeError(w, r)
+ return
+ }
+ result, err := a.billing.UpdateBillingProfile(r.Context(), input)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) {
tenantID := a.actor(r).TenantID
if tenantID == "" {
@@ -1022,6 +1304,46 @@ func (a *API) createPortalSession(w http.ResponseWriter, r *http.Request) {
writeStatusJSON(w, http.StatusCreated, result)
}
+func (a *API) getAutoTopUp(w http.ResponseWriter, r *http.Request) {
+ tenantID := a.preferenceTenantID(r, "")
+ result, err := a.billing.GetAutoTopUpSettings(r.Context(), tenantID)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) updateAutoTopUp(w http.ResponseWriter, r *http.Request) {
+ var input billing.UpdateAutoTopUpInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ input.TenantID = a.preferenceTenantID(r, input.TenantID)
+ result, err := a.billing.UpdateAutoTopUp(r.Context(), input)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) createAutoTopUpSetupSession(w http.ResponseWriter, r *http.Request) {
+ var input billing.AutoTopUpSetupInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ actor := a.actor(r)
+ input.TenantID = a.preferenceTenantID(r, input.TenantID)
+ input.CustomerEmail = actor.Email
+ result, err := a.billing.CreateAutoTopUpSetupSession(r.Context(), input)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, result)
+}
+
func (a *API) retryCheckoutSession(w http.ResponseWriter, r *http.Request) {
actor := a.actor(r)
if actor.TenantID == "" {
@@ -1383,7 +1705,11 @@ func (a *API) reload(w http.ResponseWriter, r *http.Request) {
}
func (a *API) listUsage(w http.ResponseWriter, r *http.Request) {
- query := controlplane.UsageQuery{TenantID: a.actor(r).TenantID, ProjectID: r.URL.Query().Get("project_id"), Model: r.URL.Query().Get("model"), Limit: 200}
+ query, err := a.usageQuery(r)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
result, err := a.store.ListUsage(r.Context(), query)
if err != nil {
a.databaseError(w, r, err)
@@ -1392,6 +1718,101 @@ func (a *API) listUsage(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
+func (a *API) usageDaily(w http.ResponseWriter, r *http.Request) {
+ query, err := a.usageQuery(r)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ result, err := a.store.UsageDaily(r.Context(), query)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) usageAnalytics(w http.ResponseWriter, r *http.Request) {
+ query, err := a.usageQuery(r)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ result, err := a.store.UsageAnalytics(r.Context(), query)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) usageQuery(r *http.Request) (controlplane.UsageQuery, error) {
+ actor := a.actor(r)
+ tenantID := actor.TenantID
+ if tenantID == "" {
+ tenantID = strings.TrimSpace(r.URL.Query().Get("tenant_id"))
+ }
+ query := controlplane.UsageQuery{
+ TenantID: tenantID, ProjectID: strings.TrimSpace(r.URL.Query().Get("project_id")),
+ KeyID: strings.TrimSpace(r.URL.Query().Get("key_id")), Model: strings.TrimSpace(r.URL.Query().Get("model")),
+ Provider: strings.ToLower(strings.TrimSpace(r.URL.Query().Get("provider"))), Protocol: strings.TrimSpace(r.URL.Query().Get("protocol")),
+ ErrorType: strings.TrimSpace(r.URL.Query().Get("error_type")), RequestID: strings.TrimSpace(r.URL.Query().Get("request_id")),
+ Status: strings.TrimSpace(r.URL.Query().Get("status")), Limit: 200,
+ }
+ if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
+ limit, err := strconv.Atoi(raw)
+ if err != nil || limit < 1 || limit > 1000 {
+ return controlplane.UsageQuery{}, errors.New("usage limit must be between 1 and 1000")
+ }
+ query.Limit = limit
+ }
+ if query.Status != "" && query.Status != "success" && query.Status != "error" {
+ return controlplane.UsageQuery{}, errors.New("usage status must be success or error")
+ }
+ if query.Protocol != "" && query.Protocol != string(domain.ProtocolOpenAI) && query.Protocol != string(domain.ProtocolOpenAIResponses) && query.Protocol != string(domain.ProtocolAnthropic) {
+ return controlplane.UsageQuery{}, errors.New("usage protocol is invalid")
+ }
+ if len(query.Provider) > 64 || len(query.ErrorType) > 128 {
+ return controlplane.UsageQuery{}, errors.New("usage provider or error type is too long")
+ }
+ if raw := strings.TrimSpace(r.URL.Query().Get("stream")); raw != "" {
+ value, err := strconv.ParseBool(raw)
+ if err != nil {
+ return controlplane.UsageQuery{}, errors.New("usage stream must be true or false")
+ }
+ query.Stream = &value
+ }
+ var err error
+ if query.From, err = parseUsageTime(r.URL.Query().Get("from"), false); err != nil {
+ return controlplane.UsageQuery{}, err
+ }
+ if query.To, err = parseUsageTime(r.URL.Query().Get("to"), true); err != nil {
+ return controlplane.UsageQuery{}, err
+ }
+ if !query.From.IsZero() && !query.To.IsZero() && !query.To.After(query.From) {
+ return controlplane.UsageQuery{}, errors.New("usage to must be after from")
+ }
+ return query, nil
+}
+
+func parseUsageTime(raw string, endOfDay bool) (time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, nil
+ }
+ if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
+ return parsed.UTC(), nil
+ }
+ parsed, err := time.Parse("2006-01-02", raw)
+ if err != nil {
+ return time.Time{}, errors.New("usage dates must be RFC3339 or YYYY-MM-DD")
+ }
+ if endOfDay {
+ parsed = parsed.AddDate(0, 0, 1)
+ }
+ return parsed.UTC(), nil
+}
+
func (a *API) usageSummary(w http.ResponseWriter, r *http.Request) {
result, err := a.store.UsageSummary(r.Context(), a.actor(r).TenantID, r.URL.Query().Get("project_id"))
if err != nil {
@@ -1567,6 +1988,27 @@ func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) {
status = http.StatusConflict
typeName = "topup_order_not_resolvable"
message = err.Error()
+ case errors.Is(err, billing.ErrBillingAccountNotFound):
+ status = http.StatusNotFound
+ typeName = "billing_account_not_found"
+ message = "Billing account was not found"
+ case errors.Is(err, billing.ErrPaymentMethodRequired):
+ status = http.StatusConflict
+ typeName = "payment_method_required"
+ message = "Save a payment method before enabling automatic top-up"
+ case errors.Is(err, billing.ErrAutoTopUpNeedsAttention):
+ status = http.StatusConflict
+ typeName = "payment_method_attention_required"
+ message = "Replace or re-authorize the saved payment method before enabling automatic top-up"
+ case errors.Is(err, billing.ErrInvalidBillingProfile):
+ status = http.StatusBadRequest
+ typeName = "invalid_billing_profile"
+ message = strings.TrimPrefix(err.Error(), billing.ErrInvalidBillingProfile.Error()+": ")
+ case errors.Is(err, billing.ErrBillingProfileSync):
+ status = http.StatusBadGateway
+ typeName = "billing_profile_sync_failed"
+ message = "Billing details were saved, but Stripe synchronization failed"
+ a.logger.Error("billing_profile_sync_failed", "error", err)
default:
a.logger.Error("admin_billing_error", "error", err)
}
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js
index 8d29e23..e30a0c8 100644
--- a/internal/adminui/assets/app.js
+++ b/internal/adminui/assets/app.js
@@ -1,8 +1,10 @@
const state = {
token: '', csrf: '', actor: {}, permissions: new Set(), overview: {},
tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [],
- usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], refunds: [], disputes: [], invoices: [], sessions: [],
- mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {}
+ usage: [], usageSummary: [], usageDaily: [], usageAnalytics: {models:[],providers:[]}, limits: [], users: [], audit: [], orders: [], refunds: [], disputes: [], invoices: [], sessions: [],
+ developerConfig: {base_url:'',endpoints:{}}, developerModels: [], preferences: {},
+ autoTopUp: {}, billingProfile: {},
+ mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {}, playgroundKey: '', playgroundController: null, detailModel: null, detailUsage: null
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
@@ -26,7 +28,7 @@ async function api(path, options = {}) {
function setConnected(connected) {
$('#auth-screen').classList.toggle('hidden', connected);
$('#console-app').classList.toggle('hidden', !connected);
- if (!connected) return;
+ if (!connected) { state.playgroundController?.abort();state.playgroundController=null;state.playgroundKey=''; const key=$('#playground-key');if(key)key.value=''; return; }
$('#connection-state').textContent = state.actor.role?.replaceAll('_', ' ') || 'connected';
$('#actor-label').textContent = state.actor.display_name || state.actor.email || 'Operator';
}
@@ -42,6 +44,7 @@ function scaledToDecimal(value, digits) { const number = BigInt(value || 0); con
function currencyDigits(currency) { return ['bif','clp','djf','gnf','jpy','kmf','krw','mga','pyg','rwf','ugx','vnd','vuv','xaf','xof','xpf'].includes(currency) ? 0 : ['bhd','jod','kwd','omr','tnd'].includes(currency) ? 3 : 2; }
function money(micros, currency = state.overview.billing_currency || 'usd') { return new Intl.NumberFormat(undefined, { style:'currency', currency:currency.toUpperCase(), minimumFractionDigits:2, maximumFractionDigits:6 }).format(Number(micros || 0) / 1_000_000); }
function integer(value) { return new Intl.NumberFormat().format(Number(value || 0)); }
+function chartHeightClass(value, maximum) { return `chart-height-${Math.max(1, Math.min(20, Math.ceil(Number(value || 0) / Math.max(1, Number(maximum || 0)) * 20)))}`; }
function emptyRow(span) { return `No records yet `; }
function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); }
function cookie(name) { const prefix=`${encodeURIComponent(name)}=`; const value=document.cookie.split('; ').find(item=>item.startsWith(prefix)); return value ? decodeURIComponent(value.slice(prefix.length)) : ''; }
@@ -90,13 +93,16 @@ async function getPasskey(options) {
}
async function permitted(permission, path) { if (!can(permission)) return []; return api(path); }
+function defaultUsageQuery() { const to=new Date();const from=new Date(to.getTime()-29*86400000);return new URLSearchParams({from:from.toISOString().slice(0,10),to:to.toISOString().slice(0,10)}).toString(); }
async function loadAll(knownSession = null) {
try {
const session = knownSession || await api('/me'); state.actor = session.actor || {}; state.permissions = new Set(session.permissions || []);
state.overview = await api('/overview');
+ const usageQuery=defaultUsageQuery();
const results = await Promise.all([
permitted('tenants.read','/tenants'), permitted('projects.read','/projects'), permitted('keys.read','/keys'),
- permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('usage.read','/usage'),
+ permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('overview.read','/developer/config'),
+ permitted('overview.read','/developer/models'), can('preferences.read') ? api('/developer/preferences') : {}, permitted('usage.read',`/usage?${usageQuery}`),
permitted('usage.read','/usage/summary'), permitted('limits.read','/limits'), permitted('users.read','/users'),
permitted('audit.read','/audit'), state.overview.billing_enabled ? permitted('billing.read','/billing/accounts') : [],
state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [],
@@ -105,9 +111,12 @@ async function loadAll(knownSession = null) {
state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : [],
state.overview.billing_enabled ? permitted('billing.read','/billing/refunds') : [],
state.overview.billing_enabled ? permitted('billing.read','/billing/disputes') : [],
- state.overview.billing_enabled ? permitted('billing.read','/billing/invoices') : []
+ state.overview.billing_enabled ? permitted('billing.read','/billing/invoices') : [],
+ permitted('usage.read',`/usage/daily?${usageQuery}`), permitted('usage.read',`/usage/analytics?${usageQuery}`),
+ state.overview.billing_enabled && state.actor.tenant_id && can('billing.read') ? api('/billing/auto-topup') : {},
+ state.overview.billing_enabled && state.actor.tenant_id && can('billing.read') ? api('/billing/profile') : {}
]);
- [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders,state.refunds,state.disputes,state.invoices] = results;
+ [state.tenants,state.projects,state.keys,state.providers,state.models,state.developerConfig,state.developerModels,state.preferences,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders,state.refunds,state.disputes,state.invoices,state.usageDaily,state.usageAnalytics,state.autoTopUp,state.billingProfile] = results;
renderAll(); setConnected(true); return true;
} catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; }
}
@@ -116,11 +125,16 @@ function applyPermissions() {
$$('[data-permission]').forEach(node => node.classList.toggle('hidden', !can(node.dataset.permission)));
$('#billing-tab').classList.toggle('hidden', !state.overview.billing_enabled || !can('billing.read'));
$('#topup-form').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup'));
+ $('#billing-portal').classList.toggle('hidden', !state.overview.stripe_enabled || !can('billing.topup'));
$('#account-tab').classList.toggle('hidden', !state.actor.id);
- const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="overview"]').click();
+ $('#developer-preferences-form').classList.toggle('hidden', !state.actor.tenant_id || !can('preferences.read'));
+ $('#billing-preferences-form').classList.toggle('hidden', !state.actor.tenant_id || !state.overview.billing_enabled || !can('billing.read'));
+ $('#auto-topup-panel').classList.toggle('hidden', !state.actor.tenant_id || !state.overview.billing_enabled || !can('billing.read'));
+ $('#billing-profile-panel').classList.toggle('hidden', !state.actor.tenant_id || !state.overview.billing_enabled || !can('billing.read'));
+ const active = $('.tab.active'); if (active?.classList.contains('hidden')) $('.tab[data-section="quickstart"]').click();
}
function renderAll() {
- applyPermissions(); renderOverview(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
+ applyPermissions(); renderOverview(); renderQuickstart(); renderPreferences(); renderCatalog(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor(); renderSecurity();
}
function renderOverview() {
@@ -131,23 +145,228 @@ function renderOverview() {
$('#metrics').innerHTML = items.map(([label,value,sub]) => `${label} ${esc(value)} ${esc(sub)} `).join('');
$('#overview-usage-body').innerHTML = current.map(item => `${esc(item.project_name)} ${integer(item.request_count)} ${percent(item.successful_requests,item.request_count)} ${integer(item.total_tokens)} ${money(item.cost_micros)} ${money(item.uncollected_micros)} `).join('') || emptyRow(6);
}
+function goTo(section) { const node=$(`.tab[data-section="${section}"]`); if(node){ node.click(); window.scrollTo({top:0,behavior:'smooth'}); } }
+function latestAvailableBalance() { return state.billingAccounts.find(item => !state.actor.tenant_id || item.tenant_id===state.actor.tenant_id)?.available_micros || 0; }
+function inferenceBaseURL() { return String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,''); }
+function developerEndpoint(name) { const defaults={chat_completions:'/v1/chat/completions',responses:'/v1/responses',messages:'/anthropic/v1/messages',models:'/v1/models'};const path=state.developerConfig.endpoints?.[name]||defaults[name]||'';return `${inferenceBaseURL()}${path}`; }
+function modelIsUnavailable(model) { return model?.health_status==='unavailable'; }
+function developerModelOption(item) { const unavailable=modelIsUnavailable(item);return `${esc(item.display_name||item.public_id)}${unavailable?' (unavailable)':''} `; }
+function providerHealthSummary(item) {
+ const total=Number(item.provider_count||0);const available=Number(item.available_provider_count||0);const status=item.health_status||'online';
+ if(status==='unavailable')return {status,label:'Unavailable',detail:'No provider is currently accepting traffic'};
+ if(status==='degraded')return {status,label:'Degraded',detail:`${available} of ${total} providers accepting traffic`};
+ return {status:'online',label:'Online',detail:total?`${available} of ${total} providers accepting traffic`:'Routing is available'};
+}
+function renderQuickstart() {
+ const balance=latestAvailableBalance();
+ const balanceText=state.overview.billing_enabled && can('billing.read') ? `Available balance ${money(balance)}` : state.overview.billing_enabled ? 'Balance managed by billing admin' : 'Prepaid billing disabled';
+ $('#quickstart-balance').textContent=balanceText;
+ const hasKey=state.keys.some(item=>item.status==='active');
+ const hasUsage=state.usage.some(item=>item.success);
+ const funded=!state.overview.billing_enabled || !can('billing.read') || balance>0;
+ const steps=[
+ {title:'Workspace ready',body:'Your account and default project are ready.',done:Boolean(state.actor.tenant_id||state.actor.bootstrap),action:'projects'},
+ {title:'Add balance',body:state.overview.billing_enabled?(!can('billing.read')?'Billing is managed by workspace billing members.':funded?'Funds are available for inference.':'Add funds before the first billable request.'):'Billing is not enabled.',done:funded,action:can('billing.read')?'billing':'team'},
+ {title:'Create an API key',body:hasKey?'An active key can call the gateway.':'Create a key and copy its secret once.',done:hasKey,action:hasKey?'keys':'',target:hasKey?'':'starter-key-form'},
+ {title:'Make a request',body:hasUsage?'Your first successful request is recorded.':'Run the example below and watch it appear here.',done:hasUsage,action:hasUsage?'usage':'',target:hasUsage?'':'playground-form'}
+ ];
+ $('#onboarding').innerHTML=steps.map(step=>`${step.done?'✓ ':''}${esc(step.title)} ${esc(step.body)} ${step.done?'Complete':'Open step →'} `).join('');
+ const signals=[['Models available',integer(state.developerModels.length)],['Active API keys',integer(state.keys.filter(item=>item.status==='active').length)],['This month',money(state.usageSummary.reduce((sum,item)=>sum+Number(item.cost_micros||0),0))],['Auto top-up',state.autoTopUp.enabled?'Enabled':state.autoTopUp.payment_method_configured?'Ready':'Not configured'],['Fallback model',state.preferences.fallback_model||'Not configured'],['Runtime',state.overview.redis_connected?'Live propagation':'PG fallback']];
+ $('#quickstart-signals').innerHTML=signals.map(([label,value])=>`${esc(label)} ${esc(value)}
`).join('');
+ const recent=state.usage.slice(0,5);
+ $('#quickstart-usage-body').innerHTML=recent.map(item=>`${date(item.started_at)} ${esc(item.public_model)} ${item.status_code} ${integer(item.total_tokens)} ${money(item.charged_micros||item.cost_micros)} ${integer(item.duration_ms)} ms `).join('')||emptyRow(6);
+ const modelSelect=$('#quickstart-model'); const previous=modelSelect.value||state.preferences.default_model;
+ modelSelect.innerHTML=state.developerModels.map(developerModelOption).join('')||'No available model ';
+ if(state.developerModels.some(item=>item.public_id===previous&&!modelIsUnavailable(item)))modelSelect.value=previous;
+ else{const firstAvailable=state.developerModels.find(item=>!modelIsUnavailable(item));if(firstAvailable)modelSelect.value=firstAvailable.public_id;}
+ const playgroundModel=$('#playground-model'); const playgroundPrevious=playgroundModel.value||modelSelect.value;
+ playgroundModel.innerHTML=modelSelect.innerHTML;
+ if(state.developerModels.some(item=>item.public_id===playgroundPrevious&&!modelIsUnavailable(item)))playgroundModel.value=playgroundPrevious;
+ if(state.playgroundKey)$('#playground-key').value=state.playgroundKey;
+ $('#playground-endpoint').textContent=inferenceBaseURL();
+ syncQuickstartProtocols(); syncPlaygroundProtocols(); renderQuickstartCode(); renderDeveloperAccess();
+}
+function renderDeveloperAccess() {
+ const form=$('#starter-key-form');const projects=state.projects.filter(item=>item.status==='active'&&(!state.actor.tenant_id||item.tenant_id===state.actor.tenant_id));const select=$('#starter-project');const previous=select.value;
+ select.innerHTML=projects.map(item=>`${esc(item.name)} `).join('')||'No active project ';
+ if(projects.some(item=>item.id===previous))select.value=previous;else{const preferred=projects.find(item=>item.slug==='default')||projects[0];if(preferred)select.value=preferred.id;}
+ const model=selectedDeveloperModel();$('#starter-model-label').textContent=model.public_id||'No model';
+ form.classList.toggle('hidden',!state.actor.tenant_id||!can('keys.write'));
+ $('#starter-key-submit').disabled=!select.value||!model.public_id||modelIsUnavailable(model);
+ const rows=[['OpenAI SDK base',`${inferenceBaseURL()}/v1`],['Anthropic SDK base',`${inferenceBaseURL()}/anthropic`],['Chat Completions',developerEndpoint('chat_completions')],['Responses',developerEndpoint('responses')],['Anthropic Messages',developerEndpoint('messages')],['Models',developerEndpoint('models')]];
+ $('#endpoint-list').innerHTML=rows.map(([label,value],index)=>`${esc(label)} ${esc(value)}Copy
`).join('');
+ $('#endpoint-list').dataset.values=JSON.stringify(rows.map(([,value])=>value));
+ $('#endpoint-env').textContent=`export AIGW_API_KEY="your-key"\nexport OPENAI_BASE_URL="${inferenceBaseURL()}/v1"\nexport ANTHROPIC_BASE_URL="${inferenceBaseURL()}/anthropic"`;
+}
+function renderPreferences() {
+ const preferences=state.preferences||{}; const modelOptions=state.developerModels.map(item=>`${esc(item.display_name||item.public_id)} `).join('');
+ const defaultSelect=$('#preference-default-model'); const fallbackSelect=$('#preference-fallback-model');
+ defaultSelect.innerHTML=`First available model ${modelOptions}`;
+ fallbackSelect.innerHTML=`No workspace fallback ${modelOptions}`;
+ if(state.developerModels.some(item=>item.public_id===preferences.default_model))defaultSelect.value=preferences.default_model;
+ if(state.developerModels.some(item=>item.public_id===preferences.fallback_model))fallbackSelect.value=preferences.fallback_model;
+ const canWriteDeveloper=can('developer.preferences.write'); defaultSelect.disabled=!canWriteDeveloper; fallbackSelect.disabled=!canWriteDeveloper;
+ $('#low-balance-enabled').checked=preferences.low_balance_enabled!==false;
+ $('#low-balance-threshold').value=scaledToDecimal(preferences.low_balance_threshold_micros||0,6);
+ const canWriteBilling=can('billing.preferences.write'); $('#low-balance-enabled').disabled=!canWriteBilling; $('#low-balance-threshold').disabled=!canWriteBilling;
+ $('#balance-alert-status').textContent=state.authConfig.email_delivery_enabled?'Verified billing members receive at most one low-balance alert per day.':'The preference is saved now and activates when SMTP delivery is configured.';
+}
+function selectedDeveloperModel() { return state.developerModels.find(item=>item.public_id===$('#quickstart-model')?.value) || state.developerModels.find(item=>!modelIsUnavailable(item)) || state.developerModels[0] || {}; }
+function syncQuickstartProtocols() {
+ const model=selectedDeveloperModel(); const select=$('#quickstart-protocol'); const previous=select.value;
+ select.innerHTML=(model.supported_wire_apis||[]).map(apiName=>`${esc(apiName==='chat_completions'?'OpenAI Chat Completions':apiName==='responses'?'OpenAI Responses':apiName==='messages'?'Anthropic Messages':apiName)} `).join('')||'OpenAI Chat Completions ';
+ if((model.supported_wire_apis||[]).includes(previous))select.value=previous;
+ syncProviderSelect('#quickstart-provider',model,select.value);
+}
+function selectedPlaygroundModel() { return state.developerModels.find(item=>item.public_id===$('#playground-model')?.value) || state.developerModels.find(item=>!modelIsUnavailable(item)) || state.developerModels[0] || {}; }
+function syncPlaygroundProtocols() {
+ const model=selectedPlaygroundModel(); const select=$('#playground-protocol'); const previous=select.value;
+ select.innerHTML=(model.supported_wire_apis||[]).map(apiName=>`${esc(apiName==='chat_completions'?'OpenAI Chat Completions':apiName==='responses'?'OpenAI Responses':apiName==='messages'?'Anthropic Messages':apiName)} `).join('')||'OpenAI Chat Completions ';
+ if((model.supported_wire_apis||[]).includes(previous))select.value=previous;
+ syncProviderSelect('#playground-provider',model,select.value);
+ const max=Number(model.max_output_tokens||4096);$('#playground-max-output').max=String(max>0?max:4096);
+ if(Number($('#playground-max-output').value)>max&&max>0)$('#playground-max-output').value=String(max);
+}
+function syncProviderSelect(selector,model,wire) {
+ const select=$(selector);const previous=select.value;const providers=(model.providers||[]).filter(item=>item.wire_api===wire);
+ select.innerHTML=`Automatic routing ${providers.map(item=>`${esc(item.name||item.slug)} (${esc(item.slug)})${item.state==='open'?' — unavailable':''} `).join('')}`;
+ if(providers.some(item=>item.slug===previous&&item.state!=='open'))select.value=previous;
+}
+function modelSelector(model,providerSelector) { const provider=$(providerSelector)?.value||'';const publicID=model.public_id||'model-id';return provider?`${publicID}:${provider}`:publicID; }
+function quickstartEndpoint(wire) { return wire==='messages'?developerEndpoint('messages'):wire==='responses'?developerEndpoint('responses'):developerEndpoint('chat_completions'); }
+function renderQuickstartCode() {
+ const model=selectedDeveloperModel(); const selectedModel=modelSelector(model,'#quickstart-provider'); const wire=$('#quickstart-protocol').value||'chat_completions'; const language=$('#quickstart-language').value||'curl'; const endpoint=quickstartEndpoint(wire); const key='$AIGW_API_KEY';
+ let code='';
+ if(language==='curl') {
+ const headers=wire==='messages'?`-H "x-api-key: ${key}"\n -H "anthropic-version: 2023-06-01"`:`-H "Authorization: Bearer ${key}"`;
+ const body=wire==='messages'?`{"model":"${selectedModel}","max_tokens":256,"messages":[{"role":"user","content":"Say hello in one sentence."}]}`:wire==='responses'?`{"model":"${selectedModel}","input":"Say hello in one sentence."}`:`{"model":"${selectedModel}","messages":[{"role":"user","content":"Say hello in one sentence."}],"stream":false}`;
+ code=`export AIGW_API_KEY="your-key"\ncurl ${endpoint} \\\n ${headers} \\\n -H "Content-Type: application/json" \\\n -d '${body}'`;
+ } else if(language==='python') {
+ code=wire==='messages'?`import os\nfrom anthropic import Anthropic\n\nclient = Anthropic(api_key=os.environ["AIGW_API_KEY"], base_url="${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/anthropic")\nmessage = client.messages.create(model="${selectedModel}", max_tokens=256, messages=[{"role":"user", "content":"Say hello in one sentence."}])\nprint(message.content[0].text)`:wire==='responses'?`import os\nfrom openai import OpenAI\n\nclient = OpenAI(api_key=os.environ["AIGW_API_KEY"], base_url="${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/v1")\nresponse = client.responses.create(model="${selectedModel}", input="Say hello in one sentence.")\nprint(response.output_text)`: `import os\nfrom openai import OpenAI\n\nclient = OpenAI(api_key=os.environ["AIGW_API_KEY"], base_url="${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/v1")\nresponse = client.chat.completions.create(model="${selectedModel}", messages=[{"role":"user", "content":"Say hello in one sentence."}])\nprint(response.choices[0].message.content)`;
+ } else {
+ code=wire==='messages'?`import Anthropic from "@anthropic-ai/sdk";\n\nconst client = new Anthropic({ apiKey: process.env.AIGW_API_KEY, baseURL: "${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/anthropic" });\nconst message = await client.messages.create({ model: "${selectedModel}", max_tokens: 256, messages: [{ role: "user", content: "Say hello in one sentence." }] });\nconsole.log(message.content[0].text);`: `import OpenAI from "openai";\n\nconst client = new OpenAI({ apiKey: process.env.AIGW_API_KEY, baseURL: "${String(state.developerConfig.base_url||window.location.origin).replace(/\/$/,'')}/v1" });\nconst response = await client.${wire==='responses'?'responses.create({ model: "'+selectedModel+'", input: "Say hello in one sentence." })':'chat.completions.create({ model: "'+selectedModel+'", messages: [{ role: "user", content: "Say hello in one sentence." }] })'};\nconsole.log(${wire==='responses'?'response.output_text':'response.choices[0].message.content'});`;
+ }
+ $('#quickstart-code-block code').textContent=code;
+}
+function playgroundBody(wire,model,prompt,maxOutput) {
+ if(wire==='messages')return {model,max_tokens:maxOutput,messages:[{role:'user',content:prompt}]};
+ if(wire==='responses')return {model,input:prompt,max_output_tokens:maxOutput,stream:false};
+ return {model,messages:[{role:'user',content:prompt}],max_tokens:maxOutput,stream:false};
+}
+function playgroundTokenCount(payload) {
+ const usage=payload?.usage;if(!usage)return 0;
+ return Number(usage.total_tokens ?? (Number(usage.input_tokens||usage.prompt_tokens||0)+Number(usage.output_tokens||usage.completion_tokens||0)));
+}
+function playgroundDiagnosis(status,network=false) {
+ if(network)return {title:'Gateway unreachable',message:'Verify the inference public URL, TLS certificate, and exact admin Origin allowed by the gateway.',action:'Open API keys',target:'keys'};
+ if(status===400)return {title:'Request rejected',message:'Check the selected protocol, prompt, and output limit against the model details.',action:'Review model',target:'catalog'};
+ if(status===401||status===403)return {title:'API key not authorized',message:'Use an active inference key and check its expiry, scopes, model allowlist, and monthly spend cap.',action:'Open API keys',target:'keys'};
+ if(status===402)return {title:'Balance required',message:'Add funds to the prepaid wallet, then retry this request. Automatic top-up can prevent future interruptions.',action:'Add funds',target:'billing'};
+ if(status===404)return {title:'Model route unavailable',message:'The model may be unavailable to this key or may not support the selected protocol.',action:'Choose a model',target:'catalog'};
+ if(status===409)return {title:'Request conflict',message:'Retry with a new request after the in-flight billing reservation or account change completes.',action:'View usage',target:'usage'};
+ if(status===429)return {title:'Limit reached',message:'Review request, token, concurrency, project, and API key limits before retrying.',action:'Review limits',target:'limits'};
+ if(status>=500)return {title:'Provider unavailable',message:'The gateway could not complete the upstream request after routing and failover. Keep the request ID for support.',action:'Choose another model',target:'catalog'};
+ return {title:'Request failed',message:'Review the response body and request ID. The request was not accepted as successful.',action:'View usage',target:'usage'};
+}
+function hidePlaygroundDiagnostic(){const node=$('#playground-diagnostic');node.classList.add('hidden');$('#playground-diagnostic-action').classList.add('hidden');}
+function showPlaygroundDiagnostic(status,network=false) {
+ const diagnosis=playgroundDiagnosis(status,network);const action=$('#playground-diagnostic-action');
+ $('#playground-diagnostic-title').textContent=diagnosis.title;$('#playground-diagnostic-message').textContent=diagnosis.message;
+ action.textContent=diagnosis.action;action.dataset.target=diagnosis.target;action.classList.toggle('hidden',!$(`.tab[data-section="${diagnosis.target}"]`)||$(`.tab[data-section="${diagnosis.target}"]`).classList.contains('hidden'));
+ $('#playground-diagnostic').classList.remove('hidden');
+}
+async function runPlayground(event) {
+ event.preventDefault();
+ const key=$('#playground-key').value.trim();const model=modelSelector(selectedPlaygroundModel(),'#playground-provider');const wire=$('#playground-protocol').value;const prompt=$('#playground-prompt').value.trim();const maxOutput=Number($('#playground-max-output').value);
+ if(!key||!model||!prompt||!Number.isSafeInteger(maxOutput)||maxOutput<1)return toast('Complete the API request fields',true);
+ state.playgroundKey=key;
+ const endpoint=quickstartEndpoint(wire);const headers={'Content-Type':'application/json'};
+ if(wire==='messages'){headers['X-API-Key']=key;headers['Anthropic-Version']='2023-06-01';}else headers.Authorization=`Bearer ${key}`;
+ const result=$('#playground-result');const status=$('#playground-status');const send=$('#playground-send');const stop=$('#playground-stop');
+ result.classList.remove('hidden');hidePlaygroundDiagnostic();status.className='badge';status.textContent='Sending';$('#playground-request-id').textContent='';$('#playground-duration').textContent='';$('#playground-tokens').textContent='';$('#playground-response').textContent='';
+ send.disabled=true;stop.classList.remove('hidden');const controller=new AbortController();state.playgroundController=controller;const started=performance.now();
+ try {
+ const response=await fetch(endpoint,{method:'POST',mode:'cors',credentials:'omit',cache:'no-store',headers,body:JSON.stringify(playgroundBody(wire,model,prompt,maxOutput)),signal:controller.signal});
+ const raw=await response.text();let payload;try{payload=raw?JSON.parse(raw):{};}catch{payload=raw;}
+ const requestID=response.headers.get('X-AIGW-Request-ID')||payload?.error?.request_id||'';const duration=Math.round(performance.now()-started);const tokens=playgroundTokenCount(payload);
+ status.className=`badge ${response.ok?'active':'suspended'}`;status.textContent=`${response.status} ${response.ok?'OK':'Error'}`;
+ $('#playground-request-id').textContent=requestID?`Request ${requestID}`:'';$('#playground-duration').textContent=`${integer(duration)} ms`;$('#playground-tokens').textContent=tokens?`${integer(tokens)} tokens`:'';
+ $('#playground-response').textContent=typeof payload==='string'?payload:JSON.stringify(payload,null,2);
+ if(!response.ok){showPlaygroundDiagnostic(response.status);toast(payload?.error?.message||`Request failed (${response.status})`,true);}else{hidePlaygroundDiagnostic();toast('Request completed');setTimeout(()=>loadAll(),900);}
+ } catch(error) {
+ const stopped=error.name==='AbortError';status.className='badge suspended';status.textContent=stopped?'Stopped':'Network error';$('#playground-duration').textContent=`${integer(Math.round(performance.now()-started))} ms`;$('#playground-response').textContent=stopped?'Request cancelled.':error.message;if(stopped)hidePlaygroundDiagnostic();else showPlaygroundDiagnostic(0,true);toast(stopped?'Request stopped':error.message,true);
+ } finally {
+ if(state.playgroundController===controller)state.playgroundController=null;send.disabled=false;stop.classList.add('hidden');
+ }
+}
+function renderCatalog() {
+ const ownerSelect=$('#catalog-owner');const ownerValue=ownerSelect?.value||'';const owners=[...new Set(state.developerModels.map(item=>String(item.owned_by||'').trim()).filter(Boolean))].sort((a,b)=>a.localeCompare(b));
+ if(ownerSelect){ownerSelect.innerHTML=`All developers ${owners.map(owner=>`${esc(owner)} `).join('')}`;if(owners.includes(ownerValue))ownerSelect.value=ownerValue;}
+ const search=String($('#catalog-search')?.value||'').trim().toLowerCase(); const protocol=$('#catalog-protocol')?.value||''; const input=$('#catalog-input')?.value||'';const owner=ownerSelect?.value||'';const sort=$('#catalog-sort')?.value||'newest';
+ const models=state.developerModels.filter(item=>{const hay=[item.public_id,item.display_name,item.owned_by,item.description,...(item.capabilities||[])].join(' ').toLowerCase();return (!search||hay.includes(search))&&(!protocol||(item.supported_wire_apis||[]).includes(protocol))&&(!input||(item.input_modalities||[]).includes(input))&&(!owner||item.owned_by===owner);});
+ const compareText=(a,b)=>String(a.display_name||a.public_id).localeCompare(String(b.display_name||b.public_id));
+ models.sort((a,b)=>sort==='name'?compareText(a,b):sort==='input_price'?Number(a.input_price_micros_per_million||0)-Number(b.input_price_micros_per_million||0)||compareText(a,b):sort==='output_price'?Number(a.output_price_micros_per_million||0)-Number(b.output_price_micros_per_million||0)||compareText(a,b):sort==='context'?Number(b.context_window||0)-Number(a.context_window||0)||compareText(a,b):new Date(b.released_at||0)-new Date(a.released_at||0)||compareText(a,b));
+ $('#catalog-count').textContent=`${models.length} of ${state.developerModels.length} models`;
+ $('#catalog-grid').innerHTML=models.map(item=>{const health=providerHealthSummary(item);return `${esc(item.owned_by||'MODEL')} ${esc(item.display_name||item.public_id)} ${esc(item.public_id)}
${esc(health.label)} ${esc(health.detail)}
${esc(item.description||'No description provided.')}
${(item.supported_wire_apis||[]).map(apiName=>`${esc(apiName)} `).join('')}${(item.input_modalities||[]).map(modality=>`${esc(modality)} input `).join('')}
${money(item.input_price_micros_per_million,item.price_currency)} in · ${money(item.output_price_micros_per_million,item.price_currency)} out / 1M tokens
${integer(item.context_window)} context · ${integer(item.max_output_tokens)} max output Details Use this model
`;}).join('')||'No models match these filters.
';
+}
+function modelProtocolLabel(value) { return value==='chat_completions'?'OpenAI Chat Completions':value==='responses'?'OpenAI Responses':value==='messages'?'Anthropic Messages':value; }
+function showModelDetails(publicID) {
+ const model=state.developerModels.find(item=>item.public_id===publicID);if(!model)return;
+ state.detailModel=model;$('#model-dialog-title').textContent=model.display_name||model.public_id;$('#model-dialog-id').textContent=model.public_id;
+ const lifecycle=model.lifecycle||'active';const status=lifecycle==='retired'?'retired':lifecycle==='deprecated'?'deprecated':lifecycle==='preview'?'preview':'available';
+ const health=providerHealthSummary(model);const rows=[['Status',status],['Runtime',health.label],['Providers',health.detail],['Owner',model.owned_by||'—'],['Protocols',(model.supported_wire_apis||[]).map(modelProtocolLabel).join(', ')||'—'],['Input',(model.input_modalities||[]).join(', ')||'—'],['Output',(model.output_modalities||[]).join(', ')||'—'],['Context',`${integer(model.context_window)} tokens`],['Max output',`${integer(model.max_output_tokens)} tokens`],['Released',date(model.released_at)],['Capabilities',(model.capabilities||[]).join(', ')||'—'],['Aliases',(model.aliases||[]).join(', ')||'—']];
+ $('#model-detail-grid').innerHTML=rows.map(([label,value])=>`${esc(label)} ${esc(value)}
`).join('');
+ const providers=model.providers||[];$('#model-provider-health').classList.toggle('hidden',providers.length===0);$('#model-provider-health-body').innerHTML=providers.map(item=>{const measured=Number(item.recent_samples||0)>0;const availability=measured?`${Number(item.availability_percent||0).toFixed(1)}%`:'Not measured';const latency=Number(item.header_latency_ewma_ms||0)>0?`${integer(item.header_latency_ewma_ms)} ms`:'Not measured';const retry=item.circuit_open_until?`Retry ${date(item.circuit_open_until)}`:'';return `${esc(item.name||item.slug)} ${esc(item.slug)} · ${esc(modelProtocolLabel(item.wire_api)||item.protocol||'')} ${esc(item.state==='open'?'Circuit open':item.state)} ${retry?`${esc(retry)} `:''}${esc(availability)} ${esc(latency)} ${integer(item.attempts||0)} `;}).join('');
+ $('#model-dialog-playground').disabled=modelIsUnavailable(model);$('#estimate-input').value='1000';$('#estimate-output').value='500';$('#estimate-cache-read').value='0';$('#estimate-cache-write').value='0';renderModelEstimate();$('#model-dialog').showModal();
+}
+function renderModelEstimate() {
+ const model=state.detailModel;if(!model)return;const input=Math.max(0,Number($('#estimate-input').value||0));const output=Math.max(0,Number($('#estimate-output').value||0));const cacheRead=Math.max(0,Number($('#estimate-cache-read').value||0));const cacheWrite=Math.max(0,Number($('#estimate-cache-write').value||0));const micros=(input*Number(model.input_price_micros_per_million||0)+output*Number(model.output_price_micros_per_million||0)+cacheRead*Number(model.cache_read_price_micros_per_million||0)+cacheWrite*Number(model.cache_write_price_micros_per_million||0))/1_000_000;$('#model-estimate').textContent=`Estimated cost ${money(Math.round(micros),model.price_currency||state.overview.billing_currency||'usd')}`;
+}
+function useModelInPlayground(publicID) { const model=state.developerModels.find(item=>item.public_id===publicID);if(!model||modelIsUnavailable(model)){toast('This model has no provider currently accepting traffic',true);return;}$('#quickstart-model').value=publicID;$('#playground-model').value=publicID;syncQuickstartProtocols();syncPlaygroundProtocols();$('#quickstart-provider').value='';$('#playground-provider').value='';renderQuickstartCode();if($('#model-dialog').open)$('#model-dialog').close();goTo('quickstart'); }
function renderTenants() {
$('#tenants-body').innerHTML = state.tenants.map(item => `${esc(item.name)} ${esc(item.slug)}${esc(item.status)} ${date(item.created_at)} `).join('') || emptyRow(4);
['project-tenant','key-tenant','topup-tenant','adjustment-tenant','user-tenant'].forEach(id => { const node=$(`#${id}`); if (node) node.innerHTML=selectOptions(state.tenants,'id','name', state.actor.tenant_id ? 'Current tenant' : 'Select tenant…'); });
if (state.actor.tenant_id) ['project-tenant','key-tenant','topup-tenant','adjustment-tenant','user-tenant'].forEach(id => { const node=$(`#${id}`); if (node) node.value=state.actor.tenant_id; });
}
function renderProjects() { $('#projects-body').innerHTML = state.projects.map(item => `${esc(item.name)} ${shortID(item.tenant_id)}${esc(item.slug)} ${esc(item.status)} `).join('') || emptyRow(4); renderKeyProjects(); }
-function renderKeyProjects() { const tenant = $('#key-tenant').value; const projects = state.projects.filter(item => !tenant || item.tenant_id === tenant); $('#key-project').innerHTML = selectOptions(projects,'id','name'); }
-function renderKeys() { $('#keys-body').innerHTML = state.keys.map(item => `${esc(item.name)} ${esc(item.key_prefix)}${shortID(item.project_id)}${(item.scopes||[]).map(scope=>`${esc(scope)} `).join('')} ${esc(item.status)} ${item.status==='active'&&can('keys.write')?`Revoke `:''} `).join('') || emptyRow(6); }
-function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `${esc(item.name)} ${esc(item.protocol)} ${esc(item.base_url)} ${integer(item.route_count)} ${item.enabled?'enabled':'disabled'} ${can('platform.write')?`${item.enabled?'Disable':'Enable'} `:''} `).join('') || emptyRow(6); }
+function renderKeyProjects() { const tenant=$('#key-tenant').value;const node=$('#key-project');const selected=node.value;const projects=state.projects.filter(item=>!tenant||item.tenant_id===tenant);node.innerHTML=selectOptions(projects,'id','name');if(projects.some(item=>item.id===selected))node.value=selected;else if(projects.length===1)node.value=projects[0].id; }
+function renderKeys() {
+ const picker=$('#key-models');const selected=new Set([...picker.selectedOptions].map(option=>option.value));picker.innerHTML=state.developerModels.map(item=>`${esc(item.display_name||item.public_id)} (${esc(item.public_id)}) `).join('');[...picker.options].forEach(option=>{option.selected=selected.has(option.value);});
+ $('#keys-body').innerHTML = state.keys.map(item => {
+ const models=item.allowed_models||[];const expires=item.expires_at?date(item.expires_at):'Never';const tags=(item.tags||[]).map(tag=>`${esc(tag)} `).join('');
+ const spent=Number(item.current_month_spend_micros||0);const reserved=Number(item.current_month_reserved_micros||0);const cap=Number(item.monthly_spend_micros||0);const remaining=Math.max(0,cap-spent-reserved);
+ const effectiveStatus=item.status==='active'&&item.expires_at&&new Date(item.expires_at)<=new Date()?'expired':item.status;
+ return `${esc(item.name)} ${tags?`${tags} `:''}${esc(item.key_prefix)}${shortID(item.project_id)}${(item.scopes||[]).map(scope=>`${esc(scope)} `).join('')}${models.length?`${integer(models.length)} selected model${models.length===1?'':'s'}`:'All visible models'} ${money(spent)} ${integer(item.current_month_requests)} requests · ${money(reserved)} reserved ${cap?money(cap):'Unlimited'}${cap?`${money(remaining)} remaining`:'No key-level cap'} · Expires: ${esc(expires)} ${esc(effectiveStatus)} Last used: ${esc(date(item.last_used_at))} ${item.status==='active'&&can('keys.write')?`Revoke `:''} `;
+ }).join('') || emptyRow(8);
+}
+function renderProviders() { $('#providers-body').innerHTML = state.providers.map(item => `${esc(item.name)} ${esc(item.slug)}${esc(item.protocol)} ${esc(item.wire_api)} ${esc(item.base_url)} ${integer(item.route_count)} ${item.enabled?'enabled':'disabled'} ${can('platform.write')?`${item.enabled?'Disable':'Enable'} `:''} `).join('') || emptyRow(6); }
function renderModels() { $('#models-body').innerHTML = state.models.map(item => `${esc(item.public_id)} ${esc(item.display_name||'')} · ${integer(item.context_window)} ctx · ${esc((item.input_modalities||[]).join('+'))} → ${esc((item.output_modalities||[]).join('+'))} ${esc(item.owned_by||'—')}v${item.price_version||1} ${esc(item.price_currency||'usd')} · in ${money(item.input_price_micros_per_million,item.price_currency)}/1M · out ${money(item.output_price_micros_per_million,item.price_currency)}/1M ${(item.routes||[]).map(route=>`${esc(route.provider_name||route.provider_id).slice(0,24)} → ${esc(route.upstream_model)} p${route.priority} / w${route.weight} `).join('')}
${esc(item.lifecycle||'active')} ${can('platform.write')?`${item.enabled?'Disable':'Enable'} `:''} `).join('') || emptyRow(5); }
function renderBilling() {
$('#billing-currency').textContent=(state.overview.billing_currency||'').toUpperCase();
$('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`${esc(item.tenant_name)} ${shortID(item.tenant_id)}${money(item.balance_micros,item.currency)} ${money(item.reserved_micros,item.currency)} ${money(item.available_micros,item.currency)} ${date(item.updated_at)} `).join('')||emptyRow(5);
$('#billing-ledger-body').innerHTML=state.ledger.map(item=>`${date(item.created_at)} ${shortID(item.tenant_id)}${esc(item.kind)} ${money(item.amount_micros,item.currency)} ${money(item.balance_after_micros,item.currency)} ${shortID(item.source_id)} `).join('')||emptyRow(6);
- const orders=state.orders||[];$('#billing-orders-body').innerHTML=orders.map(item=>`${date(item.created_at)} ${money(item.amount_micros,item.currency)} ${esc(item.status)} ${esc(item.reconciliation_status||'unknown')} ${item.invoice_url?`Invoice `:''} ${item.invoice_pdf_url?`PDF `:''} ${item.receipt_url?`Receipt `:''} ${['failed','expired'].includes(item.status)?`Retry `:''}${can('billing.adjust')&&item.status==='pending'&&item.reconciliation_status==='missing'?` Resolve `:''}${can('billing.adjust')&&item.status==='paid'&&item.reconciliation_status==='missing'&&!item.stripe_payment_intent_id?` Reverse `:''}${can('billing.adjust')&&['paid','partially_refunded'].includes(item.status)?` Refund `:''} `).join('')||emptyRow(6);
+ const orders=state.orders||[];$('#billing-orders-body').innerHTML=orders.map(item=>`${date(item.created_at)}${item.trigger_type==='auto'?'automatic ':''} ${money(item.amount_micros,item.currency)} ${esc(item.status)} ${esc(item.reconciliation_status||'unknown')} ${item.invoice_url?`Invoice `:''} ${item.invoice_pdf_url?`PDF `:''} ${item.receipt_url?`Receipt `:''} ${item.trigger_type!=='auto'&&['failed','expired'].includes(item.status)?`Retry `:''}${can('billing.adjust')&&item.status==='pending'&&item.reconciliation_status==='missing'?` Resolve `:''}${can('billing.adjust')&&item.status==='paid'&&item.reconciliation_status==='missing'&&!item.stripe_payment_intent_id?` Reverse `:''}${can('billing.adjust')&&['paid','partially_refunded'].includes(item.status)?` Refund `:''} `).join('')||emptyRow(6);
$('#refunds-body').innerHTML=(state.refunds||[]).map(item=>`${date(item.created_at)} ${shortID(item.topup_order_id)}${money(item.amount_micros,item.currency)} ${esc(item.status)} ${esc(item.last_error||'—')} `).join('')||emptyRow(5);
$('#disputes-body').innerHTML=(state.disputes||[]).map(item=>`${date(item.updated_at)} ${money(item.amount_micros,item.currency)} ${esc(item.status)} ${esc(item.reason)} ${date(item.due_by)} `).join('')||emptyRow(5);
+ renderBillingProfile();
+ renderAutoTopUp();
+}
+function renderBillingProfile(){
+ if(!state.actor.tenant_id)return;const item=state.billingProfile||{};const form=$('#billing-profile-form');
+ for(const name of ['legal_name','billing_email','address_line1','address_line2','city','region','postal_code','country'])form.elements[name].value=item[name]||'';
+ const labels={not_configured:'Not configured',checkout_managed:'Stripe managed',disabled:'Saved locally',pending:'Syncing',synced:'Stripe synced',failed:'Sync needed'};const status=$('#billing-profile-status');const label=labels[item.stripe_sync_status]||'Not configured';status.textContent=label;status.className=`badge ${item.stripe_sync_status==='synced'?'active':item.stripe_sync_status==='failed'?'suspended':''}`;
+ $('#billing-profile-error').textContent=item.stripe_sync_error||'';form.querySelector('button[type=submit]').disabled=!can('billing.topup');
+ $('#billing-portal').disabled=!item.stripe_customer_configured;
+}
+function renderAutoTopUp(){
+ const item=state.autoTopUp||{};if(!state.actor.tenant_id)return;
+ const currency=item.currency||state.overview.billing_currency||'usd';const digits=currencyDigits(currency);
+ $('#auto-topup-enabled').checked=Boolean(item.enabled);$('#auto-topup-threshold').value=scaledToDecimal(item.threshold_micros||0,6);$('#auto-topup-amount').value=scaledToDecimal(item.topup_amount_minor||0,digits);
+ const status=$('#auto-topup-status');status.textContent=String(item.status||'not_configured').replaceAll('_',' ');status.className=`badge ${item.enabled&&item.status==='ready'?'active':item.status==='action_required'||item.status==='failed'?'suspended':''}`;
+ $('#auto-topup-payment-method').textContent=item.payment_method_configured?`${item.payment_method_brand||item.payment_method_type||'payment method'} •••• ${item.payment_method_last4||''}${item.payment_method_exp_month?` · ${String(item.payment_method_exp_month).padStart(2,'0')}/${item.payment_method_exp_year}`:''}`:'Not saved';
+ $('#auto-topup-error').textContent=item.last_error||'';
+ const writable=can('billing.topup');$$('#auto-topup-form input').forEach(node=>node.disabled=!writable);$('#auto-topup-form button[type=submit]').disabled=!writable;$('#auto-topup-payment-setup').disabled=!writable||!item.stripe_enabled;
+ $('#auto-topup-payment-setup').textContent=item.payment_method_configured?'Replace payment method':'Save payment method';
}
function renderSecurity() {
const totp=Boolean(state.mfa?.totp_enabled);
@@ -160,8 +379,30 @@ function renderSecurity() {
$('#orders-body').innerHTML=(state.orders||[]).map(item=>`${date(item.created_at)} ${money(item.amount_micros,item.currency)} ${esc(item.status)} ${shortID(item.id)} `).join('')||emptyRow(4);
}
function renderUsage() {
+ const project=$('#usage-project'),key=$('#usage-key'),model=$('#usage-model'),provider=$('#usage-provider');const projectValue=project.value,keyValue=key.value,modelValue=model.value,providerValue=provider.value;
+ const providers=new Map();state.developerModels.forEach(item=>(item.providers||[]).forEach(route=>providers.set(route.slug,route.name||route.slug)));
+ project.innerHTML=`All projects ${state.projects.map(item=>`${esc(item.name)} `).join('')}`;key.innerHTML=`All API keys ${state.keys.map(item=>`${esc(item.name)} (${esc(item.key_prefix)}) `).join('')}`;model.innerHTML=`All models ${state.developerModels.map(item=>`${esc(item.display_name||item.public_id)} `).join('')}`;provider.innerHTML=`All providers ${[...providers].sort((a,b)=>a[1].localeCompare(b[1])).map(([slug,name])=>`${esc(name)} (${esc(slug)}) `).join('')}`;
+ if(projectValue)project.value=projectValue;if(keyValue)key.value=keyValue;if(modelValue)model.value=modelValue;if(providerValue)provider.value=providerValue;
+ if(!$('#usage-from').value){const query=new URLSearchParams(defaultUsageQuery());$('#usage-from').value=query.get('from');$('#usage-to').value=query.get('to');}
+ const points=state.usageDaily||[];const totals=points.reduce((acc,item)=>{acc.requests+=Number(item.request_count||0);acc.success+=Number(item.successful_requests||0);acc.tokens+=Number(item.total_tokens||0);acc.charged+=Number(item.charged_micros||0);acc.duration+=Number(item.average_duration_ms||0)*Number(item.request_count||0);acc.p95=Math.max(acc.p95,Number(item.p95_duration_ms||0));return acc;},{requests:0,success:0,tokens:0,charged:0,duration:0,p95:0});
+ const metrics=[['Requests',integer(totals.requests),'selected range'],['Success rate',percent(totals.success,totals.requests),'completed requests'],['Tokens',integer(totals.tokens),'input and output'],['Charged',money(totals.charged),'wallet debit'],['Average latency',totals.requests?`${integer(Math.round(totals.duration/totals.requests))} ms`:'—','request weighted'],['P95 latency',totals.p95?`${integer(totals.p95)} ms`:'—','highest daily P95']];$('#usage-metrics').innerHTML=metrics.map(([label,value,sub])=>`${label} ${esc(value)} ${esc(sub)} `).join('');
+ const maxRequests=Math.max(1,...points.map(item=>Number(item.request_count||0)));$('#usage-chart').innerHTML=points.length?`${points.map(item=>`
${new Date(item.day).toLocaleDateString(undefined,{month:'short',day:'numeric'})} `).join('')}
`:'No usage in this range
';
$('#usage-summary-body').innerHTML=state.usageSummary.map(item=>`${new Date(item.period_start).toLocaleDateString(undefined,{year:'numeric',month:'short'})} ${esc(item.project_name)} ${integer(item.request_count)} ${percent(item.successful_requests,item.request_count)} ${integer(item.input_tokens)} ${integer(item.output_tokens)} ${money(item.cost_micros)} `).join('')||emptyRow(7);
- $('#usage-events-body').innerHTML=state.usage.map(item=>`${date(item.started_at)} ${shortID(item.request_id)}${esc(item.public_model)} ${item.status_code} ${item.error_type?`${esc(item.error_type)} `:''}${integer(item.total_tokens)} ${money(item.cost_micros)} ${integer(item.duration_ms)} ms `).join('')||emptyRow(7);
+ const analytics=Array.isArray(state.usageAnalytics)?{models:[],providers:[]}:state.usageAnalytics||{models:[],providers:[]};
+ const changeLabel=item=>item.charge_change_percent==null?(Number(item.previous_charged_micros||0)===0&&Number(item.charged_micros||0)>0?'New':'—'):`${Number(item.charge_change_percent)>=0?'+':''}${Number(item.charge_change_percent).toFixed(1)}%`;
+ const changeClass=item=>item.charge_change_percent==null?(Number(item.charged_micros||0)>0?'positive':''):Number(item.charge_change_percent)>0?'money-negative':'positive';
+ const cacheRate=item=>{const denominator=Number(item.input_tokens||0)+Number(item.cache_read_input_tokens||0)+Number(item.cache_creation_input_tokens||0);return denominator?percent(item.cache_read_input_tokens,denominator):'—';};
+ $('#usage-model-analytics-body').innerHTML=(analytics.models||[]).map(item=>`${esc(item.public_model)} ${integer(item.provider_count)} provider${Number(item.provider_count)===1?'':'s'} ${integer(item.request_count)} ${percent(item.successful_requests,item.request_count)} ${integer(item.total_tokens)} ${money(item.charged_micros)} ${changeLabel(item)} ${integer(item.p95_duration_ms)} ms ${integer(item.missing_usage_requests)} `).join('')||emptyRow(8);
+ $('#usage-provider-analytics-body').innerHTML=(analytics.providers||[]).map(item=>`${esc(item.provider_name)} ${esc(item.wire_api||'unknown')} ${integer(item.request_count)} ${percent(item.successful_requests,item.request_count)} ${integer(item.model_count)} ${cacheRate(item)} ${money(item.charged_micros)} ${changeLabel(item)} ${integer(item.p95_duration_ms)} ms `).join('')||emptyRow(8);
+ $('#usage-events-body').innerHTML=state.usage.map((item,index)=>`${date(item.started_at)} ${shortID(item.request_id)}${esc(item.protocol)}${item.attempts>1?` · ${item.attempts} attempts`:''} ${esc(item.project_name||shortID(item.project_id))}${esc(item.key_name||shortID(item.key_id))} ${esc(item.public_model)}${esc(item.provider_name||'—')} ${item.status_code} ${item.error_type?`${esc(item.error_type)} `:''}${esc(item.metering_status||'')} ${integer(item.total_tokens)} ${money(item.charged_micros)}${item.uncollected_micros?`${money(item.uncollected_micros)} uncollected `:''} ${integer(item.duration_ms)} ms Details `).join('')||emptyRow(9);
+}
+function usageDiagnostic(item) {
+ return {request_id:item.request_id,started_at:item.started_at,status_code:item.status_code,success:Boolean(item.success),error_type:item.error_type||'',project:{id:item.project_id,name:item.project_name||''},api_key:{id:item.key_id,name:item.key_name||''},model:{public_id:item.public_model,provider:item.provider_name||item.provider_id||'',upstream_id:item.upstream_model||''},transport:{protocol:item.protocol,stream:Boolean(item.stream),attempts:Number(item.attempts||0),duration_ms:Number(item.duration_ms||0)},usage:{input_tokens:Number(item.input_tokens||0),output_tokens:Number(item.output_tokens||0),total_tokens:Number(item.total_tokens||0),cache_read_input_tokens:Number(item.cache_read_input_tokens||0),cache_creation_input_tokens:Number(item.cache_creation_input_tokens||0),reported:Boolean(item.usage_reported)},billing:{cost_micros:Number(item.cost_micros||0),charged_micros:Number(item.charged_micros||0),uncollected_micros:Number(item.uncollected_micros||0),metering_status:item.metering_status||''}};
+}
+function showUsageDetails(index) {
+ const item=state.usage[Number(index)];if(!item)return;state.detailUsage=item;$('#usage-dialog-title').textContent=item.public_model||'Request details';$('#usage-dialog-id').textContent=item.request_id;
+ const throughput=item.duration_ms>0&&item.output_tokens>0?`${(Number(item.output_tokens)*1000/Number(item.duration_ms)).toFixed(1)} output tokens/s`:'—';const rows=[['Time',date(item.started_at)],['Status',`${item.status_code} · ${item.success?'success':'error'}`],['Project',item.project_name||shortID(item.project_id)],['API key',item.key_name||shortID(item.key_id)],['Protocol',`${item.protocol}${item.stream?' · stream':''}`],['Public model',item.public_model],['Provider',item.provider_name||item.provider_id||'—'],['Upstream model',item.upstream_model||'—'],['Attempts',integer(item.attempts)],['Latency',`${integer(item.duration_ms)} ms`],['Throughput',throughput],['Tokens',`${integer(item.input_tokens)} in · ${integer(item.output_tokens)} out`],['Cache',`${integer(item.cache_read_input_tokens)} read · ${integer(item.cache_creation_input_tokens)} write`],['Charged',money(item.charged_micros)],['Metering',item.metering_status||'—'],['Error',item.error_type||'—']];
+ $('#usage-detail-grid').innerHTML=rows.map(([label,value])=>`${esc(label)} ${esc(value)}
`).join('');$('#usage-diagnostic').textContent=JSON.stringify(usageDiagnostic(item),null,2);$('#usage-dialog').showModal();
}
function renderLimits() {
const existing=new Map(state.limits.map(item=>[item.project_id,item]));
@@ -179,6 +420,12 @@ function addRoute() { const wrapper=document.createElement('div');wrapper.classN
document.addEventListener('click',async(event)=>{
const authTab=event.target.closest('.auth-tab');if(authTab){$$('.auth-tab').forEach(node=>node.classList.toggle('active',node===authTab));$$('.auth-pane').forEach(node=>node.classList.toggle('active',node.id===authTab.dataset.authPane));authError();return;}
const tab=event.target.closest('.tab');if(tab){$$('.tab').forEach(node=>node.classList.toggle('active',node===tab));$$('.section').forEach(node=>node.classList.toggle('active',node.id===tab.dataset.section));return;}
+ const goto=event.target.closest('[data-goto]');if(goto){goTo(goto.dataset.goto);return;}
+ const scroll=event.target.closest('[data-scroll-to]');if(scroll){document.getElementById(scroll.dataset.scrollTo)?.scrollIntoView({behavior:'smooth',block:'start'});return;}
+ const endpointCopy=event.target.closest('[data-copy-endpoint]');if(endpointCopy){try{const values=JSON.parse($('#endpoint-list').dataset.values||'[]');await navigator.clipboard.writeText(values[Number(endpointCopy.dataset.copyEndpoint)]||'');toast('Endpoint copied');}catch(error){toast('Copy failed',true);}return;}
+ const details=event.target.closest('[data-model-details]');if(details){showModelDetails(details.dataset.modelDetails);return;}
+ const useModel=event.target.closest('[data-use-model]');if(useModel){useModelInPlayground(useModel.dataset.useModel);return;}
+ const usageDetails=event.target.closest('[data-usage-details]');if(usageDetails){showUsageDetails(usageDetails.dataset.usageDetails);return;}
if(event.target.id==='reload'){try{await api('/reload',{method:'POST',body:'{}'});await loadAll();toast('Snapshot reloaded');}catch(error){toast(error.message,true);}}
if(event.target.id==='add-route')addRoute();if(event.target.closest('.remove-route'))event.target.closest('.route-row').remove();
const revokeKey=event.target.closest('[data-revoke-key]');if(revokeKey&&confirm('Revoke this API key?')){try{await api(`/keys/${revokeKey.dataset.revokeKey}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('API key revoked');}catch(error){toast(error.message,true);}}
@@ -211,10 +458,18 @@ $('#bootstrap-pane').addEventListener('submit',async(event)=>{event.preventDefau
$('#sign-out').addEventListener('click',async()=>{try{if(!state.token)await api('/auth/logout',{method:'POST',body:'{}'});}catch(error){if(error.status!==401)toast(error.message,true);}state.token='';state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);});
$('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}});
$('#project-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/projects',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Project created');}catch(error){toast(error.message,true);}});
-$('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});event.target.reset();showSecret('API key created',result.key);await loadAll();}catch(error){toast(error.message,true);}});
-$('#provider-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/providers',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Provider added');}catch(error){toast(error.message,true);}});
+$('#key-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const form=event.target;const data=formJSON(form);data.scopes=data.scopes.split(',').map(value=>value.trim()).filter(Boolean);data.tags=data.tags.split(',').map(value=>value.trim()).filter(Boolean);data.allowed_models=[...$('#key-models').selectedOptions].map(option=>option.value);data.monthly_spend_micros=data.monthly_spend.trim()?decimalToScaled(data.monthly_spend,6):0;delete data.monthly_spend;data.expires_at=data.expires_at?new Date(data.expires_at).toISOString():null;const result=await api('/keys',{method:'POST',body:JSON.stringify(data)});state.playgroundKey=result.key;form.reset();showSecret('API key created',result.key);await loadAll();goTo('quickstart');}catch(error){toast(error.message,true);}});
+$('#starter-key-form').addEventListener('submit',async(event)=>{event.preventDefault();const button=$('#starter-key-submit');try{const projectID=$('#starter-project').value;const name=$('#starter-key-name').value.trim();const model=selectedDeveloperModel();if(!projectID||!name||!model.public_id)throw new Error('An active project and model are required');button.disabled=true;const result=await api('/keys',{method:'POST',body:JSON.stringify({tenant_id:state.actor.tenant_id,project_id:projectID,name,scopes:['inference'],tags:['quickstart'],allowed_models:[model.public_id],monthly_spend_micros:0,expires_at:null})});state.playgroundKey=result.key;await loadAll();$('#playground-key').value=result.key;showSecret('Starter API key created',result.key);toast('Starter key is ready in the Playground');}catch(error){toast(error.message,true);}finally{button.disabled=false;}});
+function syncProviderWireAPI(){const form=$('#provider-form');const wire=form.elements.wire_api;const protocol=form.elements.protocol.value;wire.innerHTML=protocol==='anthropic'?'Messages ':'Chat Completions Responses ';}
+$('#provider-form [name=protocol]').addEventListener('change',syncProviderWireAPI);
+$('#provider-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/providers',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();syncProviderWireAPI();await loadAll();toast('Provider added');}catch(error){toast(error.message,true);}});
$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;for(const field of ['capabilities','input_modalities','output_modalities','regions','aliases','allowed_tenant_ids','allowed_key_ids'])data[field]=String(data[field]||'').split(',').map(value=>value.trim()).filter(Boolean);data.context_window=Number(data.context_window||0);data.max_output_tokens=Number(data.max_output_tokens||0);data.price_currency=state.overview.billing_currency||'usd';data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}});
$('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
+$('#billing-profile-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.country=String(data.country||'').toUpperCase();state.billingProfile=await api('/billing/profile',{method:'PUT',body:JSON.stringify(data)});renderBillingProfile();toast(state.billingProfile.stripe_sync_status==='synced'?'Invoice details saved and synced':'Invoice details saved');}catch(error){try{state.billingProfile=await api('/billing/profile');renderBillingProfile();}catch{}toast(error.message,true);}});
+$('#auto-topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const currency=state.autoTopUp.currency||state.overview.billing_currency||'usd';const result=await api('/billing/auto-topup',{method:'PUT',body:JSON.stringify({enabled:$('#auto-topup-enabled').checked,threshold_micros:decimalToScaled($('#auto-topup-threshold').value,6),topup_amount_minor:decimalToScaled($('#auto-topup-amount').value,currencyDigits(currency))})});state.autoTopUp=result;renderAutoTopUp();renderQuickstart();toast(result.enabled?'Automatic top-up enabled':'Automatic top-up settings saved');}catch(error){toast(error.message,true);}});
+$('#auto-topup-payment-setup').addEventListener('click',async()=>{try{const result=await api('/billing/auto-topup/setup-sessions',{method:'POST',body:'{}'});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
+$('#developer-preferences-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/developer/preferences',{method:'PUT',body:JSON.stringify({default_model:data.default_model||'',fallback_model:data.fallback_model||''})});await loadAll();toast('API defaults saved');}catch(error){toast(error.message,true);}});
+$('#billing-preferences-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/developer/preferences/billing',{method:'PUT',body:JSON.stringify({low_balance_enabled:$('#low-balance-enabled').checked,low_balance_threshold_micros:decimalToScaled($('#low-balance-threshold').value,6)})});await loadAll();toast('Balance alert saved');}catch(error){toast(error.message,true);}});
$('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}});
$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Invitation sent');}catch(error){toast(error.message,true);}});
$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.',true);}catch(error){toast(error.message,true);}});
@@ -223,6 +478,26 @@ $('#totp-confirm-form').addEventListener('submit',async(event)=>{event.preventDe
$('#passkey-form').addEventListener('submit',async(event)=>{event.preventDefault();try{if(!state.authConfig.passkeys_enabled)throw new Error('Passkeys are not configured');const data=formJSON(event.target);const begin=await api('/auth/mfa/passkey/options',{method:'POST',body:JSON.stringify({current_password:data.current_password})});const credential=await createPasskey(begin.options);await api('/auth/mfa/passkey',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,name:data.name,credential})});event.target.reset();await loadAll();toast('Passkey added');}catch(error){toast(error.message,true);}});
$('#revoke-other-sessions').addEventListener('click',async()=>{if(!confirm('Sign out every other device?'))return;try{await api('/auth/sessions/revoke-others',{method:'POST',body:'{}'});await loadAll();toast('Other devices signed out');}catch(error){toast(error.message,true);}});
$('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');});
+$('#close-model-dialog').addEventListener('click',()=>$('#model-dialog').close());$('#model-dialog-close').addEventListener('click',()=>$('#model-dialog').close());$('#model-dialog-playground').addEventListener('click',()=>{if(state.detailModel)useModelInPlayground(state.detailModel.public_id);});$('#copy-model-id').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#model-dialog-id').textContent);toast('Model ID copied');});
+$('#close-usage-dialog').addEventListener('click',()=>$('#usage-dialog').close());$('#usage-dialog-close').addEventListener('click',()=>$('#usage-dialog').close());$('#copy-request-id').addEventListener('click',async()=>{await navigator.clipboard.writeText(state.detailUsage?.request_id||'');toast('Request ID copied');});$('#copy-request-diagnostic').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#usage-diagnostic').textContent);toast('Diagnostic copied');});
+$('#model-estimate-form').addEventListener('submit',event=>event.preventDefault());
+['estimate-input','estimate-output','estimate-cache-read','estimate-cache-write'].forEach(id=>$('#'+id).addEventListener('input',renderModelEstimate));
+$('#quickstart-model').addEventListener('change',()=>{syncQuickstartProtocols();renderQuickstartCode();renderDeveloperAccess();});
+$('#quickstart-protocol').addEventListener('change',()=>{syncProviderSelect('#quickstart-provider',selectedDeveloperModel(),$('#quickstart-protocol').value);renderQuickstartCode();});
+$('#quickstart-provider').addEventListener('change',renderQuickstartCode);
+$('#quickstart-language').addEventListener('change',renderQuickstartCode);
+$('#playground-model').addEventListener('change',syncPlaygroundProtocols);
+$('#playground-protocol').addEventListener('change',()=>syncProviderSelect('#playground-provider',selectedPlaygroundModel(),$('#playground-protocol').value));
+$('#playground-form').addEventListener('submit',runPlayground);
+$('#playground-stop').addEventListener('click',()=>state.playgroundController?.abort());
+$('#playground-diagnostic-action').addEventListener('click',event=>goTo(event.currentTarget.dataset.target));
+$('#catalog-search').addEventListener('input',renderCatalog);$('#catalog-protocol').addEventListener('change',renderCatalog);$('#catalog-input').addEventListener('change',renderCatalog);$('#catalog-owner').addEventListener('change',renderCatalog);$('#catalog-sort').addEventListener('change',renderCatalog);
+$('#copy-quickstart').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('#quickstart-code-block code').textContent);toast('Example copied');}catch(error){toast('Copy failed; select the example manually',true);}});
+$('#copy-endpoint-env').addEventListener('click',async()=>{try{await navigator.clipboard.writeText($('#endpoint-env').textContent);toast('Environment copied');}catch(error){toast('Copy failed; select the environment manually',true);}});
+async function loadUsageFilters(){const params=new URLSearchParams(new FormData($('#usage-filter-form')));for(const [key,value] of [...params.entries()])if(!String(value).trim())params.delete(key);const query=params.toString();try{[state.usage,state.usageDaily,state.usageAnalytics]=await Promise.all([api(`/usage${query?`?${query}`:''}`),api(`/usage/daily${query?`?${query}`:''}`),api(`/usage/analytics${query?`?${query}`:''}`)]);renderUsage();}catch(error){toast(error.message,true);}}
+$('#usage-filter-form').addEventListener('submit',async(event)=>{event.preventDefault();await loadUsageFilters();});
+$('#usage-filter-reset').addEventListener('click',async()=>{const form=$('#usage-filter-form');form.reset();const query=new URLSearchParams(defaultUsageQuery());$('#usage-from').value=query.get('from');$('#usage-to').value=query.get('to');await loadUsageFilters();});
async function pollTopUp(orderID){for(let attempt=0;attempt<20;attempt++){const order=await api(`/billing/orders/${encodeURIComponent(orderID)}`);if(order.status==='paid'){await loadAll();toast('Balance credited');return;}if(['failed','expired'].includes(order.status)){toast(`Top-up ${order.status}`,true);return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment is still processing');}
-async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}}else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
+async function pollAutoTopUpSetup(){for(let attempt=0;attempt<20;attempt++){const settings=await api('/billing/auto-topup');if(settings.payment_method_configured){state.autoTopUp=settings;renderBilling();renderQuickstart();toast('Payment method saved; review the threshold and enable automatic top-up');return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment method setup is still processing',true);}
+async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(params.get('auth')==='register'&&state.authConfig.registration_enabled)showAuthPane('register-pane');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}else if(params.get('autotopup')==='setup'){history.replaceState({},'',location.pathname);pollAutoTopUpSetup().catch(error=>toast(error.message,true));}else if(params.get('autotopup')==='cancel'){history.replaceState({},'',location.pathname);toast('Payment method setup cancelled');}}else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
start();
diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html
index 04b4e87..8f31e1c 100644
--- a/internal/adminui/assets/index.html
+++ b/internal/adminui/assets/index.html
@@ -75,7 +75,9 @@
- Overview
+ Quickstart
+ Overview
+ Model catalog
Usage
Billing
Projects
@@ -86,10 +88,77 @@
Audit
Tenants
Providers
- Models & routes
+ Routing
-
+
+ DEVELOPER WORKSPACE
Start building Add funds
+
+
+
+
+ CONNECTION
API endpoints Copy environment
+
+
+
+
+
+
+
FIRST REQUEST
Copy a working example Copy
+
Model Protocol Provider LanguagecURL Python Node.js
+
+
Use an active API key in AIGW_API_KEY. The example never stores your key in the browser.
+
+
WORKSPACE SIGNALS What to do next
+
+
+ DEFAULTS & ALERTS
Workspace preferences
+
+ RECENT ACTIVITY
Latest requests View all usage
+ Time Model Status Tokens Charged Latency
+
+
+
+
+
OPERATIONS
Account overview Reload snapshot
CURRENT PERIOD
Project usage
@@ -98,9 +167,29 @@
+
+ API REQUEST
Request details × Copy request ID
Copy diagnostic Close
ONE-TIME SECRET
Credential created × Copy this credential now. It will not be shown again.
Copy credential
+
+
+
+ UNIFIED API
Models
+ 0 models0 routes0 APIs
+
+
+
+ Search
+ ProtocolAll protocols
+ InputAny input Text Image Audio Video
+ DeveloperAll developers
+ SortNewest Name Lowest input price Lowest output price Largest context
+
+
+
+
+ No models match these filters.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/adminui/assets/models.css b/internal/adminui/assets/models.css
new file mode 100644
index 0000000..dc7c9c9
--- /dev/null
+++ b/internal/adminui/assets/models.css
@@ -0,0 +1,64 @@
+:root { --bg:#f4f6f7; --panel:#fff; --ink:#17212b; --muted:#657583; --line:#d9e1e5; --nav:#102a3a; --accent:#146c94; --accent-soft:#e6f2f6; --good:#187151; --warn:#7b5e14; --bad:#a23f45; font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
+* { box-sizing:border-box; }
+body { margin:0; background:var(--bg); color:var(--ink); font-size:14px; }
+button,input,select { font:inherit; }
+button { cursor:pointer; }
+.catalog-header { min-height:66px; padding:12px max(24px,calc((100% - 1240px)/2)); display:flex; align-items:center; justify-content:space-between; gap:20px; background:var(--nav); }
+.catalog-brand { display:grid; grid-template-columns:38px auto; grid-template-rows:20px 14px; column-gap:10px; color:#fff; text-decoration:none; }
+.catalog-brand > span { grid-row:1/-1; width:38px; height:38px; border:1px solid #8fd0df; display:grid; place-items:center; color:#b8eef7; font-weight:800; }
+.catalog-brand strong { font-size:15px; align-self:end; }
+.catalog-brand small { color:#8ba9b9; font-size:9px; }
+.catalog-header nav { display:flex; gap:8px; }
+.button { min-height:40px; padding:0 15px; border:1px solid transparent; display:inline-flex; align-items:center; justify-content:center; font-weight:700; text-decoration:none; }
+.button.primary { color:#fff; background:var(--accent); }
+.button.secondary { color:var(--accent); background:#fff; border-color:var(--line); }
+.catalog-header .button.secondary { color:#b8eef7; background:transparent; border-color:#537486; }
+main { width:min(1240px,calc(100% - 48px)); margin:30px auto 64px; }
+.catalog-intro { display:flex; justify-content:space-between; align-items:end; gap:24px; margin-bottom:22px; }
+.eyebrow { color:var(--accent); font-size:10px; font-weight:800; }
+h1 { margin:7px 0 0; font-size:32px; line-height:1.1; }
+.catalog-stats { display:flex; gap:22px; color:var(--muted); font-size:12px; }
+.catalog-stats strong { display:block; color:var(--ink); font-size:20px; }
+.catalog-filters { display:grid; grid-template-columns:2fr repeat(4,minmax(0,1fr)); gap:12px; padding:18px; margin-bottom:18px; background:var(--panel); border:1px solid var(--line); }
+label { display:flex; flex-direction:column; gap:7px; color:var(--muted); font-size:12px; font-weight:650; }
+input,select { width:100%; min-height:40px; padding:9px 11px; border:1px solid var(--line); color:var(--ink); background:#fff; outline:none; }
+input:focus,select:focus { border-color:#69a9bf; box-shadow:0 0 0 3px var(--accent-soft); }
+.catalog-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:14px; }
+.model-card { min-width:0; display:flex; flex-direction:column; gap:12px; padding:18px; background:var(--panel); border:1px solid var(--line); box-shadow:0 8px 24px rgba(29,47,61,.05); }
+.model-card header { display:flex; justify-content:space-between; align-items:start; gap:12px; }
+.model-card header small { color:var(--muted); }
+.model-card h2 { margin:4px 0 0; font-size:17px; overflow-wrap:anywhere; }
+.model-card > code { color:#486071; font-size:12px; overflow-wrap:anywhere; }
+.model-card > p { min-height:63px; margin:0; color:#526673; line-height:1.5; display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; }
+.health { flex:0 0 auto; padding:4px 7px; border:1px solid var(--line); font-size:11px; white-space:nowrap; }
+.health.available { color:var(--good); background:#eaf7f0; border-color:#c7e9d9; }
+.health.degraded { color:var(--warn); background:#fff8dc; border-color:#e9d990; }
+.health.unavailable { color:var(--bad); background:#fff0f0; border-color:#f0cccc; }
+.model-tags,.detail-tags { display:flex; flex-wrap:wrap; gap:5px; }
+.model-tags span,.detail-tags span { padding:4px 7px; color:#4c6572; background:#eef3f5; font-size:11px; }
+.model-card dl { display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; margin:0; padding-top:12px; border-top:1px solid var(--line); }
+.model-card dl div { min-width:0; }
+dt { color:var(--muted); font-size:10px; }
+dd { margin:5px 0 0; font-size:12px; font-weight:700; overflow-wrap:anywhere; }
+.model-card > .button { margin-top:auto; align-self:flex-start; }
+.catalog-status { padding:28px; color:var(--muted); text-align:center; background:#fff; border:1px solid var(--line); }
+.catalog-status[role="alert"] { color:var(--bad); background:#fff6f6; }
+.hidden { display:none !important; }
+dialog { width:min(720px,calc(100% - 32px)); max-height:calc(100vh - 32px); padding:0; border:0; box-shadow:0 18px 70px rgba(0,0,0,.25); }
+dialog::backdrop { background:rgba(16,42,58,.5); }
+.model-dialog { padding:24px; overflow:auto; }
+.model-dialog > header { display:flex; justify-content:space-between; align-items:start; gap:16px; padding-bottom:16px; border-bottom:1px solid var(--line); }
+.model-dialog h2 { margin:5px 0 5px; font-size:24px; }
+.model-dialog code { color:#486071; overflow-wrap:anywhere; }
+.icon-button { width:36px; height:36px; border:1px solid var(--line); background:#fff; color:var(--muted); font-size:23px; line-height:1; }
+.model-dialog > p { margin:18px 0; color:#526673; line-height:1.55; }
+.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 22px; margin:18px 0; }
+.detail-grid > div { display:flex; justify-content:space-between; gap:14px; padding:11px 0; border-bottom:1px solid var(--line); }
+.detail-grid dd { text-align:right; }
+.price-estimator { padding-top:18px; border-top:1px solid var(--line); }
+.price-estimator h3 { margin:5px 0 14px; font-size:16px; }
+.estimate-inputs { display:grid; grid-template-columns:1fr 1fr 1fr; align-items:end; gap:12px; }
+.estimate-inputs output { min-height:40px; display:flex; align-items:center; justify-content:center; color:var(--good); background:#f0fbf5; border:1px solid #c7e9d9; font-weight:750; }
+.model-dialog footer { display:flex; justify-content:flex-end; gap:8px; margin-top:22px; }
+@media (max-width:900px) { .catalog-filters { grid-template-columns:repeat(2,minmax(0,1fr)); } .search-field { grid-column:1/-1; } .catalog-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
+@media (max-width:620px) { .catalog-header { padding:12px; } .catalog-brand small { display:none; } .catalog-header .button { padding:0 11px; } main { width:calc(100% - 24px); margin-top:20px; } .catalog-intro { align-items:start; flex-direction:column; } .catalog-stats { width:100%; justify-content:space-between; } .catalog-filters,.catalog-grid,.detail-grid,.estimate-inputs { grid-template-columns:1fr; } .search-field { grid-column:auto; } .model-card > p { min-height:0; } .model-dialog { padding:20px; } .model-dialog footer { flex-direction:column; } }
diff --git a/internal/adminui/assets/models.html b/internal/adminui/assets/models.html
new file mode 100644
index 0000000..0af0f50
--- /dev/null
+++ b/internal/adminui/assets/models.html
@@ -0,0 +1,50 @@
+
+
+