feat: merge opus comfyui and login updates
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-20
|
||||
@@ -0,0 +1,220 @@
|
||||
## Context
|
||||
|
||||
The frontend `/routes` page is end-to-end broken: `POST /api/routes/rebuild`
|
||||
in `src/routes/admin/compat.rs:368-370` was a stub that returned
|
||||
`{ success: true }` without touching `token_routes` / `route_channels`, and
|
||||
`GET /api/models/token-candidates` was unregistered. Sites and accounts
|
||||
configure cleanly, models populate `model_availability` via the existing
|
||||
`POST /api/accounts/{id}/models/refresh` path, but nothing ever fanned that
|
||||
out into route rows, so `TokenRoutes.tsx` rendered "暂无路由" no matter
|
||||
what.
|
||||
|
||||
The proposal calls for three capabilities — `route-model-discovery`,
|
||||
`route-rebuild-pipeline`, `route-candidate-api` — plus an auto-trigger
|
||||
hook on account creation. This document records what was actually built
|
||||
and the trade-offs we accepted relative to the proposal.
|
||||
|
||||
**Stakeholders:**
|
||||
- Backend (this change): Rust handler authors maintaining `routes::admin`.
|
||||
- Frontend (`TokenRoutes.tsx:411-412`, `:252`): consumes the rebuild
|
||||
summary and the candidates payload — contracts are load-bearing.
|
||||
- Operators: re-runs `POST /api/routes/rebuild` whenever model topology
|
||||
shifts; relies on `manual_override = 1` channels surviving rebuilds.
|
||||
|
||||
**Constraints:**
|
||||
- SQLite as the backing store; no schema migrations in this change.
|
||||
- Existing `model_availability` table is the source of truth for model
|
||||
presence — already filled by the unmodified
|
||||
`POST /api/accounts/{id}/models/refresh` handler.
|
||||
- `manual_override = 1` rows in `route_channels` are user-curated and
|
||||
must survive every rebuild, untouched.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Turn `POST /api/routes/rebuild` into a real pipeline that reads
|
||||
`model_availability` (filtered by `accounts.status` and
|
||||
`site_disabled_models`) and writes `token_routes` + `route_channels`.
|
||||
- Return a `RebuildSummary` whose camelCase fields match the
|
||||
`TokenRoutes.tsx:411-412` toast contract (`createdRoutes`,
|
||||
`createdChannels`).
|
||||
- Register `GET /api/models/token-candidates` returning the four-field
|
||||
shape `TokenRoutes.tsx:252` already calls.
|
||||
- Fire a background rebuild after account creation so first-time users
|
||||
don't have to click "重建" before the page works.
|
||||
- Preserve `display_name` / `display_icon` / `routing_strategy` on
|
||||
re-runs — manual edits on `token_routes` must not be clobbered.
|
||||
|
||||
**Non-Goals:**
|
||||
- Live `/v1/models` discovery (`route-model-discovery` capability). The
|
||||
proposal sketched per-platform fetchers (OpenAI / Anthropic / generic)
|
||||
and a per-`(site, account)` cache. We did not build them. Reason: the
|
||||
existing `model_availability` table is already filled by the refresh
|
||||
endpoint, and "就用现成的" was the explicit call. Re-scoped as a
|
||||
potential follow-up change.
|
||||
- `modelsMissingTokenGroups` and `endpointTypesByModel` content. The
|
||||
legacy Node handler computes these from a pricing-catalog fetcher and
|
||||
a marketplace-cache file that don't exist in this codebase. Returning
|
||||
`{}` keeps the contract intact; the frontend tolerates empty objects.
|
||||
- New crates. We reused `sqlx`, `tokio`, and `axum` already in the tree.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1. Read from `model_availability` instead of fetching upstream
|
||||
|
||||
**Choice:** The rebuild reads `model_availability ⋈ accounts` (filtered
|
||||
by `accounts.status = 'active'` and an `EXISTS NOT` against
|
||||
`site_disabled_models`).
|
||||
|
||||
**Alternatives considered:**
|
||||
- Build the per-platform fetcher trait the proposal described and call
|
||||
it inline during rebuild. Rejected: doubles the scope, ads network
|
||||
failure modes to a write-path that should be deterministic, and the
|
||||
table is already kept current by the refresh endpoint.
|
||||
- Fetch on-demand only when `model_availability` is empty for an
|
||||
account. Rejected: hard to reason about — user can't tell whether a
|
||||
rebuild used cached or fresh data.
|
||||
|
||||
**Rationale:** Separation of concerns. "Refresh models for an account"
|
||||
is one job (already implemented); "rebuild routes from known models"
|
||||
is another. Composing them via the table keeps each path testable.
|
||||
|
||||
### D2. Single transaction for the whole rebuild
|
||||
|
||||
**Choice:** `state.db.begin()` at the top, `tx.commit()` at the bottom.
|
||||
Every read and write goes through `&mut *tx`.
|
||||
|
||||
**Alternatives:**
|
||||
- Per-route transaction so partial failures still produce some routes.
|
||||
Rejected: `token_routes` and `route_channels` get out of sync if we
|
||||
abort halfway, which is exactly the bug we're fixing.
|
||||
|
||||
**Rationale:** SQLite write throughput is dominated by fsync, not lock
|
||||
contention; one big transaction is faster than many small ones for the
|
||||
sizes we expect (tens to low hundreds of accounts). Rebuild is rare
|
||||
enough that holding the write lock for the duration is acceptable.
|
||||
|
||||
### D3. Reuse existing `token_routes` rows by `model_pattern`
|
||||
|
||||
**Choice:** Pre-load `(model_pattern → id)` into a `HashMap`, look up
|
||||
each model name in the map, only `INSERT` when absent.
|
||||
|
||||
**Alternatives:**
|
||||
- `INSERT ... ON CONFLICT DO NOTHING` and then `SELECT` the id back.
|
||||
Rejected: requires a unique index on `model_pattern` we didn't want
|
||||
to add as a side effect; also two round-trips per model.
|
||||
- Truncate-and-rewrite. Rejected: drops user-edited
|
||||
`display_name`/`display_icon`.
|
||||
|
||||
**Rationale:** O(1) lookup per model, one query upfront, manual edits
|
||||
preserved.
|
||||
|
||||
### D4. `manual_override = 1` channels are sacred
|
||||
|
||||
**Choice:** When reconciling channels under a route, partition existing
|
||||
rows into `manual_keys` (skipped entirely) and `auto_by_key`
|
||||
(reconciled). The desired-set excludes any `(account, model)` covered
|
||||
by a manual row, so we never insert an auto row that would shadow a
|
||||
manual one.
|
||||
|
||||
**Rationale:** Operators set `manual_override = 1` precisely to opt out
|
||||
of automation. Stomping it would erase intent.
|
||||
|
||||
### D5. `token_id` selection is best-effort, nullable-tolerant
|
||||
|
||||
**Choice:** When inserting a new `route_channels` row, pick the
|
||||
account's default-flagged enabled token via
|
||||
`ORDER BY COALESCE(is_default, 0) DESC, id ASC LIMIT 1`. If the account
|
||||
has no tokens, store `NULL` and let the proxy layer's existing
|
||||
"account-level token" fallback handle it at request time.
|
||||
|
||||
**Alternatives:**
|
||||
- Skip channels for accounts with no tokens. Rejected: the proposal
|
||||
surfaces these as `modelsWithoutToken` in the candidates API; they
|
||||
should still appear in the route list so the operator sees them.
|
||||
- Loop over every enabled token and create one channel per token.
|
||||
Rejected: explodes `route_channels` and conflates "account routing"
|
||||
with "token routing", which the schema treats as separate concerns.
|
||||
|
||||
### D6. Auto-rebuild via fire-and-forget `tokio::spawn`
|
||||
|
||||
**Choice:** `spawn_route_rebuild(state.clone())` after a successful
|
||||
account insert in both the single-account and batch branches of
|
||||
`POST /api/accounts`. Errors are logged at `warn`, the response is not
|
||||
delayed.
|
||||
|
||||
**Alternatives:**
|
||||
- Synchronous rebuild in the request path. Rejected: adds rebuild
|
||||
latency to every account creation, including ones where models
|
||||
haven't been refreshed yet (so the rebuild would be a no-op). Worst
|
||||
case: a slow rebuild times out the HTTP request.
|
||||
- Job queue / cron. Rejected: introduces infrastructure for a single
|
||||
trigger; we already have the `/api/routes/rebuild` endpoint as the
|
||||
manual fallback.
|
||||
|
||||
**Rationale:** The user's actual flow is "add account → refresh models
|
||||
→ rebuild fires → routes appear". Auto-trigger handles step 3 without
|
||||
blocking step 1.
|
||||
|
||||
### D7. Token-candidates returns two real fields, two empty objects
|
||||
|
||||
**Choice:** `models` and `modelsWithoutToken` are computed from
|
||||
`model_availability ⋈ account_tokens ⋈ accounts ⋈ sites`.
|
||||
`modelsMissingTokenGroups` and `endpointTypesByModel` are returned as
|
||||
`{}`.
|
||||
|
||||
**Rationale:** Legacy Node behavior depends on
|
||||
`fetchModelPricingCatalog` and `readModelsMarketplaceCache`, neither of
|
||||
which has a Rust counterpart in this tree. Returning empty objects
|
||||
satisfies the schema; the UI degrades gracefully (no missing-group
|
||||
warnings, no per-model endpoint chips). When/if a marketplace cache
|
||||
lands, this handler is the place to wire it in.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Stale `model_availability`]** → A rebuild reflects whatever was in
|
||||
the table at run time. Mitigation: the existing
|
||||
`POST /api/accounts/{id}/models/refresh` is the contract for
|
||||
freshness; the auto-rebuild fires after account create, and the
|
||||
manual rebuild button covers re-runs.
|
||||
- **[Long rebuild blocks writes]** → A single transaction holds the
|
||||
write lock for the duration of the rebuild. For tens of accounts and
|
||||
hundreds of models, expected runtime is sub-second. If the install
|
||||
scales past low thousands of channels, switch D2 to per-route
|
||||
transactions or move the rebuild to a background worker.
|
||||
- **[Auto-rebuild noise]** → Fires on every account create, even when
|
||||
`model_availability` for that account is still empty (rebuild becomes
|
||||
a no-op writing nothing). Acceptable: cost is one short transaction
|
||||
with empty desired sets. Logged at `info` so operators can see it.
|
||||
- **[Empty `endpointTypesByModel`]** → Frontend can't show endpoint
|
||||
chips. Cosmetic; not blocking. Documented as a non-goal so
|
||||
follow-ups know where to plug the marketplace cache.
|
||||
- **[`manual_override` semantics depend on `BOOLEAN` storage]** →
|
||||
SQLite stores booleans as 0/1 integers. We decode via `Option<bool>`
|
||||
and `unwrap_or(false)`; rows with `NULL` are treated as auto. Matches
|
||||
schema default.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No schema changes. Deploy is a code-only swap:
|
||||
|
||||
1. Ship the new binary. The stub handler is replaced atomically; first
|
||||
request after the swap exercises the new pipeline.
|
||||
2. Run `POST /api/routes/rebuild` once after deploy to backfill
|
||||
existing installs whose `model_availability` is already populated.
|
||||
3. New account creations from this point on auto-rebuild.
|
||||
|
||||
**Rollback:** revert the binary. The new code never wrote to a column
|
||||
or table that didn't already exist, and `manual_override` rows are
|
||||
untouched, so rollback restores the stub without data loss.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should `route-model-discovery` ship as a separate change, or is
|
||||
"refresh endpoint fills the table" considered the permanent design?
|
||||
Tracked outside this change for now.
|
||||
- Do we want a debounce on the auto-rebuild when batch-creating many
|
||||
accounts in quick succession? Current behavior fires once per
|
||||
`POST /api/accounts` request (covers batch via the multi-token
|
||||
branch), which is fine. If callers ever script many sequential
|
||||
single-account POSTs, consider coalescing.
|
||||
@@ -0,0 +1,46 @@
|
||||
## Why
|
||||
|
||||
前端 `/routes` 页面打开后路由列表完全空白,根因是后端 `POST /api/routes/rebuild` 是个空 stub(`src/routes/admin/compat.rs:368-370` 只回 `success: true`),`/api/models/token-candidates` 端点根本没注册。即使站点和账号都配好了,`token_routes` / `route_channels` 这两张表也没人去填,前端只能渲染"暂无路由"。要让路由功能从配好账号到出现在列表里端到端可用,必须把"扫上游模型 → 过滤 → 写表"这条流水线在后端补齐。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **新增**:多平台模型发现(OpenAI 兼容 / Anthropic / 自定义),从站点 `url + api_key + custom_headers` 拉 `/v1/models`,结果按 `(site_id, account_id)` 缓存并尊重 `site_disabled_models` 黑名单。
|
||||
- **新增**:`POST /api/routes/rebuild` 真实实现 — 扫描所有 active 账号,按 `model_name` 聚合为一条 `token_routes` 记录,每个 `(account, source_model)` 落一条 `route_channels` 记录,多账号自动合并到同一 route 下。响应体包含 `rebuild.createdRoutes` / `createdChannels` 以满足 `TokenRoutes.tsx:411-412` 的现有契约。
|
||||
- **新增**:`GET /api/models/token-candidates` 端点 — 返回 `{ models, modelsWithoutToken, modelsMissingTokenGroups, endpointTypesByModel }`,给前端"候选模型"面板和新建路由弹窗的下拉用(`TokenRoutes.tsx:252` 已经在调)。
|
||||
- **新增**:每次"账号成功添加"后由 service 层异步触发一次 rebuild,避免用户必须手点重建按钮。
|
||||
- **修改**:`compat.rs` 中保留的 `rebuild_routes` 占位 handler 删除,迁移到独立 service 模块。
|
||||
- 不破坏已有 `GET /api/routes` / `/lite` / `/summary` 与 `route_channels` 写表 schema。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `route-model-discovery`: 站点级模型清单的获取、缓存与黑名单过滤。包含多平台 fetcher trait(OpenAI / Anthropic / 自定义 header)、`site_disabled_models` 应用、超时与重试策略、按账号 credential mode(`apikey` / oauth)选取认证头。
|
||||
- `route-rebuild-pipeline`: 把发现到的模型合成路由的写库流水线。负责创建/更新 `token_routes`(按模型名聚合)、为每个 `(account, source_model)` 创建 `route_channels`、保留手工 `manual_override = 1` 通道、回滚已下线模型对应的通道。同步回填 `rebuild.createdRoutes` / `createdChannels` 字段。
|
||||
- `route-candidate-api`: `GET /api/models/token-candidates` 端点。基于 `route-model-discovery` 的缓存返回前端需要的 4 个聚合字段,区分"账号有 token 能用"、"账号在但没 token"、"账号 token 缺组"三种状态。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无(`openspec/specs/` 当前为空,本仓库还没有沉淀过 spec,所以全部走新建)。
|
||||
|
||||
## Impact
|
||||
|
||||
**新增代码:**
|
||||
- `src/services/model_discovery/` — 模型 fetcher trait + 三个 impl(openai / anthropic / generic),缓存层。
|
||||
- `src/services/route_rebuild/` — 重建流水线。
|
||||
- `src/routes/admin/model_candidates.rs` — `GET /api/models/token-candidates` handler。
|
||||
- `src/routes/admin/compat.rs` — 把 `rebuild_routes` 改写为调用新 service。
|
||||
|
||||
**接触的表:**
|
||||
- 读:`sites`, `accounts`, `account_tokens`, `site_disabled_models`
|
||||
- 写:`token_routes`, `route_channels`
|
||||
|
||||
**外部依赖:**
|
||||
- 复用现有 `reqwest` 客户端 + `tokio` runtime,不引入新 crate。
|
||||
- 复用 `state.config.proxy.upstream_timeout_secs` 做模型拉取超时。
|
||||
|
||||
**前端:**无需改动。`TokenRoutes.tsx` 和 `api.ts` 当前调用契约保持不变,列表自然出现。
|
||||
|
||||
**风险:**
|
||||
- 模型扫描走外网,慢账号会拖时间。流水线必须 per-site 并发 + 单 site 超时。
|
||||
- `route_channels` 已有数据时不能粗暴 truncate,需保留 `manual_override = 1` 的人工通道。
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: `GET /api/models/token-candidates` is registered
|
||||
|
||||
The admin router SHALL register `GET /api/models/token-candidates`
|
||||
behind the standard admin auth layer. The handler SHALL return a JSON
|
||||
object with exactly four top-level keys: `models`,
|
||||
`modelsWithoutToken`, `modelsMissingTokenGroups`, `endpointTypesByModel`.
|
||||
|
||||
#### Scenario: Endpoint returns 200 with the four-key shape
|
||||
- **WHEN** an authenticated admin sends `GET /api/models/token-candidates`
|
||||
- **THEN** the response is HTTP 200 with a JSON body containing keys
|
||||
`models`, `modelsWithoutToken`, `modelsMissingTokenGroups`, and
|
||||
`endpointTypesByModel` — no extra top-level keys, no missing keys
|
||||
|
||||
#### Scenario: Unauthenticated request is rejected
|
||||
- **WHEN** a request without a valid admin token reaches the endpoint
|
||||
- **THEN** the admin auth layer returns 401 before the handler runs
|
||||
|
||||
### Requirement: `models` lists every (model, token) candidate
|
||||
|
||||
The `models` field SHALL be an object whose keys are model names and
|
||||
whose values are arrays of candidate descriptors. A `(model, token)`
|
||||
combination SHALL appear in the `models` array for that model exactly
|
||||
when ALL of the following hold:
|
||||
- a row in `model_availability` reports `available = 1` for the model
|
||||
on the token's account
|
||||
- the token's `enabled` is truthy (NULL treated as enabled)
|
||||
- the token's `value_status` is `'ready'` (NULL treated as ready)
|
||||
- the account's `status = 'active'`
|
||||
- the site's `status` is `'active'` (NULL treated as active)
|
||||
|
||||
Each candidate descriptor SHALL contain: `accountId`, `tokenId`,
|
||||
`tokenName`, `isDefault` (boolean), `username`, `siteId`, `siteName`.
|
||||
Within a single model's array, descriptors SHALL be deduplicated by
|
||||
`tokenId`.
|
||||
|
||||
#### Scenario: Active token with available model appears
|
||||
- **WHEN** account 5 (active, site active) has token 11 (enabled,
|
||||
ready) and `model_availability(account=5, model='gpt-4')` is
|
||||
available
|
||||
- **THEN** `models['gpt-4']` includes a descriptor with
|
||||
`accountId = 5, tokenId = 11`
|
||||
|
||||
#### Scenario: Disabled token is excluded
|
||||
- **WHEN** account 5 has token 11 with `enabled = 0`
|
||||
- **THEN** no descriptor with `tokenId = 11` appears in `models` for
|
||||
any model
|
||||
|
||||
#### Scenario: Duplicate tokens in source data deduped
|
||||
- **WHEN** the underlying join produces two rows for the same
|
||||
`(model, tokenId)` pair (e.g., joins fan out)
|
||||
- **THEN** the descriptor appears exactly once in
|
||||
`models[model_name]`
|
||||
|
||||
### Requirement: `modelsWithoutToken` surfaces accounts missing tokens
|
||||
|
||||
The `modelsWithoutToken` field SHALL be an object keyed by model name.
|
||||
For each `(account, model)` combination where the model is available
|
||||
on the active account but no qualifying token (per the `models`
|
||||
requirement) exists for that account, the handler SHALL include a
|
||||
descriptor `{ accountId, username, siteId, siteName }` under that
|
||||
model name. Descriptors SHALL be deduplicated by `accountId` within
|
||||
each model's array.
|
||||
|
||||
#### Scenario: Account with available model but no enabled tokens
|
||||
- **WHEN** account 8 reports `gpt-4` available but has zero rows in
|
||||
`account_tokens` (or all rows are disabled)
|
||||
- **THEN** `modelsWithoutToken['gpt-4']` includes a descriptor with
|
||||
`accountId = 8`, and that account does NOT appear in `models['gpt-4']`
|
||||
|
||||
#### Scenario: Account with token does not appear here
|
||||
- **WHEN** account 5 reports `gpt-4` available and has a usable token
|
||||
- **THEN** account 5 does NOT appear in `modelsWithoutToken['gpt-4']`
|
||||
|
||||
### Requirement: `modelsMissingTokenGroups` and `endpointTypesByModel` are empty
|
||||
|
||||
The handler SHALL return `modelsMissingTokenGroups` and
|
||||
`endpointTypesByModel` as empty JSON objects (`{}`). The frontend
|
||||
contract permits empty objects and these fields SHALL NOT be
|
||||
populated until a marketplace-cache or pricing-catalog source is
|
||||
added to the backend.
|
||||
|
||||
#### Scenario: Both fields are empty objects
|
||||
- **WHEN** the handler returns successfully under any state of the
|
||||
database
|
||||
- **THEN** `response.modelsMissingTokenGroups === {}` and
|
||||
`response.endpointTypesByModel === {}`
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Route rebuild reads from `model_availability`
|
||||
|
||||
The rebuild pipeline SHALL derive its working set from the
|
||||
`model_availability` table joined to `accounts`, filtering out:
|
||||
- accounts whose `status` is not `'active'`
|
||||
- rows whose `available` is not `1` (treating `NULL` as not available)
|
||||
- `(account, model)` combinations covered by `site_disabled_models`
|
||||
for the account's `site_id`
|
||||
|
||||
The pipeline SHALL NOT make outbound HTTP requests to upstream platforms
|
||||
during a rebuild. Refreshing `model_availability` is the responsibility
|
||||
of the existing `POST /api/accounts/{id}/models/refresh` endpoint and
|
||||
is out of scope for this requirement.
|
||||
|
||||
#### Scenario: Inactive account is excluded
|
||||
- **WHEN** an account with `status = 'suspended'` has rows in
|
||||
`model_availability`
|
||||
- **THEN** no `token_routes` row is created on its behalf and no
|
||||
`route_channels` row references its `account_id`
|
||||
|
||||
#### Scenario: Site-disabled model is excluded
|
||||
- **WHEN** `site_disabled_models` contains `(site_id = 7, model_name = 'gpt-x')`
|
||||
and an active account on site 7 has `model_availability` for `gpt-x`
|
||||
- **THEN** the rebuild does not include that account in the channel set
|
||||
for the `gpt-x` route
|
||||
|
||||
### Requirement: Rebuild aggregates one route per distinct model name
|
||||
|
||||
The rebuild SHALL ensure exactly one row exists in `token_routes` for
|
||||
every distinct `model_name` in the filtered working set, keyed by
|
||||
`model_pattern = model_name`. The rebuild SHALL reuse existing rows
|
||||
when their `model_pattern` already matches; new rows SHALL be inserted
|
||||
with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, and
|
||||
`enabled = 1`.
|
||||
|
||||
The rebuild MUST NOT overwrite `display_name`, `display_icon`, or any
|
||||
other fields on existing `token_routes` rows. Operators rely on these
|
||||
fields being editable from the frontend without being clobbered on
|
||||
re-runs.
|
||||
|
||||
#### Scenario: New model creates a new route
|
||||
- **WHEN** model `claude-4-opus` appears in the working set and no
|
||||
`token_routes` row has `model_pattern = 'claude-4-opus'`
|
||||
- **THEN** the rebuild inserts a `token_routes` row with
|
||||
`model_pattern = 'claude-4-opus'`, `routing_strategy = 'weighted'`,
|
||||
`route_mode = 'pattern'`, `enabled = 1`
|
||||
|
||||
#### Scenario: Existing route is reused without overwrite
|
||||
- **WHEN** a `token_routes` row exists with
|
||||
`model_pattern = 'gpt-4'` and `display_name = 'GPT-4 Turbo (Custom)'`
|
||||
- **THEN** the rebuild leaves `display_name` unchanged and reuses the
|
||||
same `id` for channel reconciliation
|
||||
|
||||
### Requirement: Channel reconciliation preserves manual overrides
|
||||
|
||||
For each route, the rebuild SHALL reconcile `route_channels` rows
|
||||
keyed by `(account_id, source_model)` against the desired set derived
|
||||
from `model_availability`. Rows with `manual_override = 1` SHALL be
|
||||
treated as immutable: not inserted, not updated, not deleted. Rows
|
||||
with `manual_override = 0` (or `NULL`) SHALL be:
|
||||
- inserted when missing from the existing set
|
||||
- hard-deleted when no longer in the desired set
|
||||
|
||||
The desired set SHALL exclude any `(account, model)` combination
|
||||
already covered by a `manual_override = 1` row, so the rebuild never
|
||||
inserts an auto channel that shadows a manual one.
|
||||
|
||||
#### Scenario: Manual channel survives rebuild
|
||||
- **WHEN** a `route_channels` row has `manual_override = 1` and its
|
||||
`(account_id, source_model)` is no longer in the desired set
|
||||
- **THEN** the row is left untouched and counted in
|
||||
`RebuildSummary.skipped_manual_channels`
|
||||
|
||||
#### Scenario: Stale auto channel is deleted
|
||||
- **WHEN** a `route_channels` row has `manual_override = 0`,
|
||||
`account_id = 12`, `source_model = 'old-model'`, and no longer
|
||||
appears in the desired set
|
||||
- **THEN** the row is deleted and counted in
|
||||
`RebuildSummary.deleted_channels`
|
||||
|
||||
#### Scenario: New auto channel is inserted with default token
|
||||
- **WHEN** account 12 newly reports `gpt-5` available and has at least
|
||||
one enabled `account_tokens` row
|
||||
- **THEN** a new `route_channels` row is inserted with `route_id`
|
||||
pointing at the `gpt-5` route, `account_id = 12`, the
|
||||
default-flagged enabled token's id (or the lowest id when none is
|
||||
default), `priority = 0`, `weight = 10`, `enabled = 1`,
|
||||
`manual_override = 0`
|
||||
|
||||
#### Scenario: Account without tokens still gets a channel
|
||||
- **WHEN** account 13 reports `gpt-5` available but has no enabled
|
||||
`account_tokens` rows
|
||||
- **THEN** a `route_channels` row is inserted with `token_id = NULL`,
|
||||
relying on the proxy layer's account-level token fallback
|
||||
|
||||
### Requirement: Rebuild runs in a single transaction
|
||||
|
||||
The rebuild SHALL execute every read and write inside one database
|
||||
transaction, committed at the end. A partial failure MUST NOT leave
|
||||
`token_routes` and `route_channels` mutually inconsistent.
|
||||
|
||||
#### Scenario: Failure mid-rebuild rolls back
|
||||
- **WHEN** an `INSERT` into `route_channels` fails partway through
|
||||
the rebuild
|
||||
- **THEN** the transaction aborts and no `token_routes` insert from
|
||||
the same rebuild persists
|
||||
|
||||
### Requirement: Rebuild summary matches frontend contract
|
||||
|
||||
The rebuild SHALL return a `RebuildSummary` containing the fields
|
||||
`createdRoutes`, `reusedRoutes`, `createdChannels`, `deletedChannels`,
|
||||
`skippedManualChannels`, `scannedModels`, `scannedAccounts` (camelCase
|
||||
when serialized). The `POST /api/routes/rebuild` handler SHALL embed
|
||||
this object under the JSON key `rebuild`, alongside `success: true`.
|
||||
|
||||
`TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` and
|
||||
`rebuild.createdChannels`; both MUST be present and SHALL count only
|
||||
rows actually inserted by the current rebuild (not pre-existing rows
|
||||
that were reused).
|
||||
|
||||
#### Scenario: Rebuild response shape
|
||||
- **WHEN** the handler completes successfully
|
||||
- **THEN** the response body is
|
||||
`{ "success": true, "rebuild": { "createdRoutes": <n1>,
|
||||
"reusedRoutes": <n2>, "createdChannels": <n3>,
|
||||
"deletedChannels": <n4>, "skippedManualChannels": <n5>,
|
||||
"scannedModels": <n6>, "scannedAccounts": <n7> } }`
|
||||
|
||||
#### Scenario: Reused route does not increment createdRoutes
|
||||
- **WHEN** every model in the working set has a pre-existing
|
||||
`token_routes` row
|
||||
- **THEN** `createdRoutes = 0` and `reusedRoutes` equals the number of
|
||||
distinct models scanned
|
||||
|
||||
### Requirement: Auto-rebuild after account creation
|
||||
|
||||
The system SHALL fire one route rebuild in the background after
|
||||
`POST /api/accounts` returns success for at least one created
|
||||
account. The HTTP response SHALL return without waiting for the
|
||||
rebuild to finish. Errors from the background rebuild SHALL be logged
|
||||
at `warn` level and SHALL NOT affect the response status or body.
|
||||
|
||||
#### Scenario: Single-account create triggers rebuild
|
||||
- **WHEN** `POST /api/accounts` creates exactly one account
|
||||
- **THEN** `spawn_route_rebuild` is invoked once with the cloned
|
||||
`AppState` and the response returns immediately
|
||||
|
||||
#### Scenario: Batch-create with all failures does not trigger
|
||||
- **WHEN** `POST /api/accounts` returns with `created.len() == 0` and
|
||||
`failed.len() > 0`
|
||||
- **THEN** `spawn_route_rebuild` is not invoked
|
||||
@@ -0,0 +1,52 @@
|
||||
## 1. Service module
|
||||
|
||||
- [x] 1.1 Create `src/services/route_rebuild.rs` with `RebuildSummary` struct (camelCase serde) and `pub async fn rebuild_routes(&AppState) -> AppResult<RebuildSummary>`
|
||||
- [x] 1.2 Implement transactional pipeline: open `state.db.begin()`, do all reads/writes through `&mut *tx`, commit at the end
|
||||
- [x] 1.3 Query `model_availability ⋈ accounts` filtering by `accounts.status = 'active'`, `available = 1`, and `NOT EXISTS site_disabled_models`
|
||||
- [x] 1.4 Group rows by model name into `HashMap<String, Vec<i64>>` and pre-load existing `(model_pattern, id)` from `token_routes` for reuse
|
||||
- [x] 1.5 Insert new `token_routes` rows with `routing_strategy = 'weighted'`, `route_mode = 'pattern'`, `enabled = 1`; never overwrite existing display fields
|
||||
- [x] 1.6 Per route, partition existing `route_channels` rows into `manual_keys` (skip) vs `auto_by_key` (reconcile)
|
||||
- [x] 1.7 Insert missing auto channels with default-token pick (`ORDER BY COALESCE(is_default,0) DESC, id ASC LIMIT 1`); allow `token_id = NULL` when no tokens exist
|
||||
- [x] 1.8 Hard-delete auto channels that fell out of the desired set; count into `RebuildSummary.deleted_channels`
|
||||
- [x] 1.9 Expose the module from `src/services/mod.rs` (`pub mod route_rebuild;`)
|
||||
|
||||
## 2. Rebuild HTTP handler
|
||||
|
||||
- [x] 2.1 Replace the stub `rebuild_routes()` in `src/routes/admin/compat.rs:368` with a handler that takes `State<AppState>` and calls `services::route_rebuild::rebuild_routes`
|
||||
- [x] 2.2 Return `Json(json!({ "success": true, "rebuild": summary }))` so `TokenRoutes.tsx:411-412` reads `rebuild.createdRoutes` / `createdChannels`
|
||||
|
||||
## 3. Token-candidates handler
|
||||
|
||||
- [x] 3.1 Create `src/routes/admin/model_candidates.rs` with `pub async fn token_candidates(State<AppState>) -> AppResult<Json<Value>>`
|
||||
- [x] 3.2 Query `(model, account, token, site)` join filtering active sites/accounts, enabled tokens, `value_status = 'ready'`; build `models` map deduped by `tokenId` per model
|
||||
- [x] 3.3 Query `(model, account, site)` join for available models on active accounts; diff against the covered set to populate `modelsWithoutToken` deduped by `accountId` per model
|
||||
- [x] 3.4 Return all four keys: `models`, `modelsWithoutToken`, `modelsMissingTokenGroups: {}`, `endpointTypesByModel: {}`
|
||||
- [x] 3.5 Mount `GET /api/models/token-candidates` under the admin auth layer in `src/routes/admin/mod.rs`
|
||||
|
||||
## 4. Auto-rebuild after account create
|
||||
|
||||
- [x] 4.1 Add `fn spawn_route_rebuild(state: AppState)` helper at the bottom of `src/routes/admin/accounts.rs` that `tokio::spawn`s the rebuild and logs the summary at `info`, errors at `warn`
|
||||
- [x] 4.2 Call `spawn_route_rebuild(state.clone())` in the single-account success branch of `create_account`
|
||||
- [x] 4.3 Call `spawn_route_rebuild(state.clone())` in the batch branch when `created.len() > 0`; skip when no accounts were created
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- [x] 5.1 `cargo build` clean — no warnings on the new code
|
||||
- [x] 5.2 Confirm `compat.rs:19` route mount still points at the (now non-stub) `rebuild_routes` handler
|
||||
- [x] 5.3 Confirm `accounts.rs:228` (batch) and `:248` (single) success paths both fire the auto-rebuild
|
||||
|
||||
<!--
|
||||
Descoped (not implemented in this change, see design.md "Non-Goals"):
|
||||
|
||||
- `route-model-discovery` capability (per-platform `/v1/models` fetcher
|
||||
trait with per-(site, account) cache). Deferred per "就用现成的"
|
||||
instruction; existing `POST /api/accounts/{id}/models/refresh` fills
|
||||
`model_availability`, so the pipeline reads from there.
|
||||
- Marketplace pricing-catalog source for `endpointTypesByModel` and
|
||||
`modelsMissingTokenGroups`. Returning `{}` is the documented contract;
|
||||
populate when a Rust-side cache lands.
|
||||
|
||||
These are tracked as potential follow-up changes, not work owed by this
|
||||
change.
|
||||
-->
|
||||
|
||||
Reference in New Issue
Block a user