feat: merge opus comfyui and login updates

This commit is contained in:
boos
2026-05-21 16:32:04 +02:00
parent c16479da7c
commit 9c84759498
105 changed files with 20160 additions and 1034 deletions
@@ -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` 的人工通道。
@@ -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 === {}`
@@ -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.
-->