Skip to content

Commit 04110d8

Browse files
committed
feat: ship account routing engine
1 parent 1ea2193 commit 04110d8

29 files changed

Lines changed: 1141 additions & 217 deletions

.agents/skills/gettokens-claude-code-account-list/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ description: GetTokens Claude Code 账号列表:Claude Channel Routing、Anthr
1717
- `dedicated / prefer / ordered / weighted / canary` 只作为上游兼容输入,不进入 Claude 新 UI / Wails DTO / engine policy。
1818
- `exclude` 不是 route mode,只能作为请求级 deny 或 pool filter。
1919
- 旧 allow / deny / order / fallback 只作为请求级兼容 policy,不作为新页面主配置模型。
20+
- 禁用优先级高于 session sticky、失败降级和 retry;禁用账号或禁用组不能被 sticky / fallback 继续使用。
21+
- 激活账号只重新进入可路由账号池,等待下一轮 route / retry,不抢占当前 stream / sticky。
22+
- 失败冷却状态必须持久化到运行态或 guard source;401/429/5xx/model-unavailable 后续请求和 explain 都应读取同一冷却状态,自动恢复不能清 `manual-disabled`
2023
- P0 账号筛选条件:`AccountRecord.supportedFormats` 包含 `anthropic`
2124
- Claude Code 本地仍只写一个 relay endpoint / relay key;多账号轮换发生在 GetTokens relay 内。
2225
- 不把 provider 名称等于 `claude` 作为筛选条件。

.agents/skills/gettokens-codex-account-list/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ description: GetTokens Codex 账号列表:Codex Channel Routing、账号请求
2121
- `openai-compatible`
2222
- 禁用账号保留在排序中,但不参与运行时请求候选。
2323
-`codex-api-key`,禁用不能只停留在 GetTokens 本地 store:sidecar `codex-api-key` 配置必须保存 `disabled:true`,CLIProxyAPI synthesizer 必须生成 disabled runtime auth,之后由 `manual-disabled` route guard 排除候选。
24+
- 禁用优先级高于 session sticky、失败降级和 retry;Codex WebSocket pinned auth 命中禁用后必须释放 pin、断开旧 upstream,并在下一请求边界重新进入 route engine。
25+
- 激活账号只重新进入可路由账号池,等待下一轮 route / retry,不抢占当前 stream / sticky。
26+
- 失败冷却状态必须持久化到运行态或 guard source;401/429/5xx/model-unavailable 后续请求和 explain 都应读取同一冷却状态,自动恢复不能清 `manual-disabled`
2427
- 项目模式只限定目标账号或账号组;命中账号组后,组内选择继续使用 `sequential``balanced`
2528

2629
## 2. 前端结构

app.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,29 @@ func (a *App) ExplainChannelRouting(input ChannelRoutingExplainInput) (*ChannelR
263263
ProjectName: input.ProjectName,
264264
TriedAccountIDs: append([]string(nil), input.TriedAccountIDs...),
265265
ActiveSessions: cloneIntMap(input.ActiveSessions),
266+
StickyAccountID: input.StickyAccountID,
266267
})
267268
if err != nil {
268269
return nil, err
269270
}
270271
return mapChannelRoutingExplainResult(result), nil
271272
}
272273

274+
func (a *App) MarkChannelRouteAccountResult(input ChannelRouteAccountResultInput) (*ChannelAccountRuntimeState, error) {
275+
result, err := a.core.MarkChannelRouteAccountResult(wailsapp.ChannelRouteAccountResultInput{
276+
AccountID: input.AccountID,
277+
StatusCode: input.StatusCode,
278+
ErrorType: input.ErrorType,
279+
Reason: input.Reason,
280+
CooldownSeconds: input.CooldownSeconds,
281+
Model: input.Model,
282+
})
283+
if err != nil {
284+
return nil, err
285+
}
286+
return mapChannelAccountRuntimeState(result), nil
287+
}
288+
273289
func (a *App) ListChannelRouteEvents(input ChannelRouteEventsInput) ([]ChannelRouteEvent, error) {
274290
result, err := a.core.ListChannelRouteEvents(wailsapp.ChannelRouteEventsInput{
275291
Channel: input.Channel,
@@ -301,6 +317,27 @@ func (a *App) ListChannelRouteEvents(input ChannelRouteEventsInput) ([]ChannelRo
301317
return out, nil
302318
}
303319

320+
func mapChannelAccountRuntimeState(input *wailsapp.ChannelAccountRuntimeState) *ChannelAccountRuntimeState {
321+
if input == nil {
322+
return nil
323+
}
324+
sources := make(map[string]ChannelRuntimeStateSource, len(input.Sources))
325+
for key, source := range input.Sources {
326+
sources[key] = ChannelRuntimeStateSource{
327+
Source: source.Source,
328+
Reason: source.Reason,
329+
Model: source.Model,
330+
ExpiresAt: source.ExpiresAt,
331+
UpdatedAt: source.UpdatedAt,
332+
}
333+
}
334+
return &ChannelAccountRuntimeState{
335+
AccountID: input.AccountID,
336+
Sources: sources,
337+
UpdatedAt: input.UpdatedAt,
338+
}
339+
}
340+
304341
func (a *App) UploadAuthFiles(files []UploadFilePayload) error {
305342
payload := make([]wailsapp.UploadFilePayload, 0, len(files))
306343
for _, file := range files {

app_types.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ type ChannelRoutingExplainInput struct {
221221
ProjectName string `json:"projectName,omitempty"`
222222
TriedAccountIDs []string `json:"triedAccountIDs,omitempty"`
223223
ActiveSessions map[string]int `json:"activeSessions,omitempty"`
224+
StickyAccountID string `json:"stickyAccountID,omitempty"`
224225
}
225226

226227
type ChannelRoutingExplainResult struct {
@@ -283,6 +284,29 @@ type ChannelRouteEvent struct {
283284
Redacted bool `json:"redacted"`
284285
}
285286

287+
type ChannelRouteAccountResultInput struct {
288+
AccountID string `json:"accountID"`
289+
StatusCode int `json:"statusCode,omitempty"`
290+
ErrorType string `json:"errorType,omitempty"`
291+
Reason string `json:"reason,omitempty"`
292+
CooldownSeconds int `json:"cooldownSeconds,omitempty"`
293+
Model string `json:"model,omitempty"`
294+
}
295+
296+
type ChannelAccountRuntimeState struct {
297+
AccountID string `json:"accountID"`
298+
Sources map[string]ChannelRuntimeStateSource `json:"sources,omitempty"`
299+
UpdatedAt string `json:"updatedAt,omitempty"`
300+
}
301+
302+
type ChannelRuntimeStateSource struct {
303+
Source string `json:"source"`
304+
Reason string `json:"reason,omitempty"`
305+
Model string `json:"model,omitempty"`
306+
ExpiresAt string `json:"expiresAt,omitempty"`
307+
UpdatedAt string `json:"updatedAt,omitempty"`
308+
}
309+
286310
type UpdateOAuthModelAliasesInput struct {
287311
Channel string `json:"channel"`
288312
Models []OpenAICompatibleModel `json:"models,omitempty"`

docs-linhay/dev/20260524-account-routing-engine.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ GetTokens 下一阶段自定义端点路由应收敛为 `AccountRoutingEngine`
1313
3. 自定义端点路由本质是候选账号重写与排序,不是请求拦截。
1414
4. CLIProxyAPI fork 后续要持续合并上游,自定义逻辑必须集中到稳定扩展层。
1515

16-
本次 rollout 同时需要清理既有账号路由逻辑。已经实现的 `RoutePolicy``AccountRouteGuardStore`、rate-limit policy、session affinity selector 和 WebSocket pinned auth 特例不能作为另一套路由系统长期并行存在;它们要么迁移为 engine policy,要么保留为明确的兼容 shim。
16+
本次 rollout 同时需要清理既有账号路由逻辑。已经实现的 `RoutePolicy``AccountRouteGuardStore`、rate-limit evaluator、session affinity selector 和 WebSocket pinned auth 特例不能作为另一套路由系统长期并行存在;它们要么迁移为 engine policy,要么保留为明确的兼容 shim。
1717

1818
## 推荐架构
1919

@@ -63,6 +63,11 @@ RouteContext Normalize
6363

6464
`P0` 不允许被后续策略绕过。请求级 allow/order 不能把手动禁用、限流阻断、冷却中或模型不可用账号放回候选。
6565

66+
启停状态的实时性不对称:
67+
68+
- 禁用立即生效:账号 `disabled``manual-disabled``inventoryGroup.enabled=false``channelGroup.enabled=false` 高于 `StickyPolicy`、失败降级、retry 和 selector。若当前 stream / pinned auth / sticky 正在使用该账号,执行器必须在 request-boundary 或管理控制可达的最近边界断开连接、释放 pin,并让后续请求重新进入路由引擎。
69+
- 激活非抢占:账号或账号组恢复激活后,只进入后续可路由账号池;不会抢占当前正在工作的 stream,不主动迁移已有 sticky,也不会因为“刚激活”立刻替换当前账号。
70+
6671
## 简化后的核心路由语义
6772

6873
Account Routing Engine 的用户主概念收敛为两步:
@@ -238,6 +243,18 @@ AND supports channel/provider/model/endpoint
238243

239244
配置或状态变化时重建快照;请求热路径不做 DB 查询和复杂解析。
240245

246+
### 运行态持久化
247+
248+
失败冷却必须由 `ResultRecorder / MarkResult` 写入可恢复的运行态存储或 guard source,而不是只存在于当前 selector 进程内存。至少区分:
249+
250+
- `manual-disabled`:用户或配置意图,只能由用户启用或配置变更清除。
251+
- `rate-limit` / `cooldown`:429、配额窗口或短期熔断,可按窗口到期自动清理。
252+
- `auth-error`:401、token expired、credential invalid,默认保持异常,直到凭证刷新或用户显式恢复。
253+
- `model-unavailable`:模型不可用或账号不支持该模型,可按模型探测或配置变更恢复。
254+
- `upstream-error`:5xx、连接失败、超时,可进入短期冷却并记录过期时间。
255+
256+
`MarkResult` 只负责写运行态和 guard source;下一次请求或 retry 仍通过 `AccountRoutingEngine.Route()` 读取快照后决策。自动恢复只能清理对应 source,不能误清 `manual-disabled`。账号激活会清除对应禁用 source 并使账号进入下一轮候选池,但不触发当前连接抢占。
257+
241258
## Trace 与 Explain
242259

243260
每次决策应能产出简洁 trace:
@@ -272,6 +289,8 @@ Codex WebSocket 不承诺 mid-response 迁移。支持的边界是下一条 down
272289
4. 强制 transcript replay。
273290
5. 重新进入 `AccountRoutingEngine.Route()`
274291

292+
如果 guard 命中来源是 `manual-disabled`、账号 `disabled`、全局组禁用或渠道组禁用,不能等待 sticky 自然过期;需要把当前 pinned auth 视为立即不可用。已经开始输出的 stream 不做无缝续流,但应在最近可控边界主动断开并给出 trace,后续请求再根据 retry/fallback 重新选择。反向的激活操作只影响下一轮选择,不主动恢复或替换当前连接。
293+
275294
## 上游合并边界
276295

277296
GetTokens 自定义能力应放在 GetTokens-owned 包,例如:
@@ -304,7 +323,7 @@ GetTokens 自定义能力应放在 GetTokens-owned 包,例如:
304323

305324
1. P0 作为兼容层保留。
306325
2. `gettokensRoutePolicy``accountRouteGuardPolicy` 可以先映射为 engine policy。
307-
3. endpoint route policy 上线后,逐步把 rate-limit 双路径、session affinity wrapper 收敛到 engine。
326+
3. endpoint route policy 上线后,逐步把 session affinity wrapper 等剩余 selector shim 收敛到 engine。
308327

309328
## 既有逻辑清理边界
310329

@@ -339,8 +358,18 @@ GetTokens 自定义能力应放在 GetTokens-owned 包,例如:
339358
- `ListChannelRouteEvents` 输出只含安全摘要,不携带 payload / token / cookie / bearer。
340359
- Codex / Claude Channel Routing workbench 已加入 shadow 开关与 shadow explain 展示。
341360

361+
2026-05-25 后续收敛:
362+
363+
- CLIProxyAPI fork 默认 service builder 已接入 `AccountRouteGuardResultHook`,真实执行器 `MarkResult` 可把 401、429、408/5xx/timeout 写入 route guard transient sources,并在成功后只清 transient source,不清 `manual-disabled`
364+
- Codex / Claude 账号列表已经移除旧 allow / deny / fallback 的主 UI 操作入口;路由探测只按渠道当前账号顺序传入 `orderAccountIDs`,旧字段保留为空作为 request policy 兼容层。
365+
- dev sidecar 真实 upstream 冒烟已完成:`GET /v1/models` 返回 `status=200 models=8``POST /v1/responses` 使用 `gpt-5.4``max_output_tokens=1` 返回 `status=200 object=response`
366+
- Codex / Claude Channel Routing workbench 已展示最近 route event ledger,桌面模式读取 `ListChannelRouteEvents`,浏览器预览 Explain 后合成 redacted preview event。
367+
- `rateLimitPolicy` 兼容注册已删除;rate-limit evaluator 只刷新 `AccountRouteGuardSourceRateLimit`,热路径由 `accountRouteGuardPolicy` 统一 deny。
368+
- session affinity legacy path 已在 sticky selector 前复用 `RoutePolicy` / engine seam;sticky cache 和 fallback 只能在 guard 过滤后的候选池内工作。
369+
- session affinity 已进一步作为 manager-local `PolicyStageSticky` 接入 scheduler fast path:cache hit 通过 route engine 排序候选,cache miss 由 selector 选中后绑定结果。
370+
- WebSocket request-boundary 特例已收口为单一连接生命周期 helper:guarded pinned auth 释放 pin、关闭旧 execution session、强制 transcript replay。
371+
- `legacy-routing-cleanup-v01.md` 已更新当前 shim 状态:公共 `RoutePolicy` 兼容 API 是后续上游合并与旧 request policy 的主要兼容边界。
372+
342373
仍未完成的项:
343374

344-
- 真实 upstream 请求冒烟与完整 selector 热路径接管。
345-
- route event ledger 的更完整审计入口。
346-
- 旧路径的最后收敛和删除清单。
375+
- 完整 selector 热路径接管与旧 shim 删除。

0 commit comments

Comments
 (0)