diff --git a/app_mappers.go b/app_mappers.go index dba40234..2fd41288 100644 --- a/app_mappers.go +++ b/app_mappers.go @@ -490,7 +490,6 @@ func mapRateLimitRuleToCore(input RateLimitRule) wailsapp.RateLimitRule { return wailsapp.RateLimitRule{ ID: input.ID, AccountKey: input.AccountKey, - MatchKey: input.MatchKey, Strategy: input.Strategy, Window: input.Window, LimitValue: input.LimitValue, @@ -517,7 +516,6 @@ func mapRateLimitRule(item wailsapp.RateLimitRule) RateLimitRule { return RateLimitRule{ ID: item.ID, AccountKey: item.AccountKey, - MatchKey: item.MatchKey, Strategy: item.Strategy, Window: item.Window, LimitValue: item.LimitValue, @@ -535,7 +533,6 @@ func mapRateLimitState(input *wailsapp.RateLimitState) *RateLimitState { } return &RateLimitState{ AccountKey: input.AccountKey, - MatchKey: input.MatchKey, Blocked: input.Blocked, BlockReason: input.BlockReason, Rules: mapRateLimitRuleStates(input.Rules), @@ -581,7 +578,6 @@ func mapRateLimitEvents(items []wailsapp.RateLimitEvent) []RateLimitEvent { out = append(out, RateLimitEvent{ ID: item.ID, AccountKey: item.AccountKey, - MatchKey: item.MatchKey, RuleID: item.RuleID, Strategy: item.Strategy, Window: item.Window, diff --git a/app_types.go b/app_types.go index f342119a..9e8bb4df 100644 --- a/app_types.go +++ b/app_types.go @@ -720,7 +720,6 @@ type RateLimitStrategyMeta struct { type RateLimitRule struct { ID string `json:"id,omitempty"` AccountKey string `json:"accountKey"` - MatchKey string `json:"matchKey,omitempty"` Strategy string `json:"strategy"` Window string `json:"window"` LimitValue int64 `json:"limitValue"` @@ -741,7 +740,6 @@ type RateLimitRuleState struct { type RateLimitState struct { AccountKey string `json:"accountKey"` - MatchKey string `json:"matchKey,omitempty"` Blocked bool `json:"blocked"` BlockReason string `json:"blockReason,omitempty"` Rules []RateLimitRuleState `json:"rules"` @@ -751,7 +749,6 @@ type RateLimitState struct { type RateLimitEvent struct { ID string `json:"id"` AccountKey string `json:"accountKey"` - MatchKey string `json:"matchKey,omitempty"` RuleID string `json:"ruleID"` Strategy string `json:"strategy"` Window string `json:"window"` diff --git a/docs-linhay/dev/account-card-identity-model.md b/docs-linhay/dev/account-card-identity-model.md new file mode 100644 index 00000000..fe7140fd --- /dev/null +++ b/docs-linhay/dev/account-card-identity-model.md @@ -0,0 +1,41 @@ +# 账号卡身份模型 + +## 结论 + +GetTokens 不引入用户实体。业务归属实体只有账号卡,账号卡 ID 即 `account_key` / `account_id`,用于 usage attribution、rate-limit、route guard、账号详情和配置编辑的稳定关联。 + +## 身份边界 + +- `account_key`:唯一业务身份,代表一张账号卡。 +- `auth-id` / `auth-index` / `source_hash` / provider / OAuth subject / email / API key hash:运行态证据,只用于诊断、关联和迁移辅助。 +- `attribution_key`:usage evidence,不允许作为 rate-limit 配置匹配键。 + +## 创建与更新语义 + +- 账号登录、新增 API key、导入、复制:创建新账号卡,分配新的 `account_key`。 +- 重新登录、编辑当前卡凭证:更新当前账号卡的凭证和 runtime evidence,保留原 `account_key`。 +- 两张账号卡即使凭证内容完全相同,也必须拥有不同 `account_key`。 + +## 当前实现映射 + +- Codex API key:GetTokens 本地 store 生成并持久化 `local-id`,sidecar runtime `Auth.AccountKey` 使用该值。 +- Standalone sidecar Codex API key:缺失 `local-id` 时生成 `codex-api-key:legacy-*` 并写回 `config.yaml`。 +- auth-file:`Auth.AccountKey = auth-file:`。 +- OpenAI-compatible provider:`Auth.AccountKey = openai-compatible:`。 + +## Rate-limit 规则 + +Rate-limit 是账号卡资产级策略: + +- `rate_limit_rules` 只存 `account_key`。 +- `rate_limit_events` 只存 `account_key`。 +- evaluator 查询 usage 只允许 `account_key = ?`。 +- `match_key` 已破坏性移除,不做旧版本兼容。 + +## UI 规则 + +账号详情中的限流规则默认显示单行摘要。点击编辑进入配置态,配置态以可换行表单行展示,不使用横向滚动宽表。保存成功后回到单行摘要。 + +## 前端运行边界 + +账号详情组件不直接绑定 Wails rate-limit CRUD。`RateLimitRulesAPI` 由页面 shell 注入:desktop 注入真实 Wails 方法,browser preview 注入 `undefined` 并使用状态快照只做展示和布局验收。这样 preview 不会因为缺少 `window.go.main.App` 而崩溃,也能防止组件绕过账号卡身份模型直接调用旧接口。 diff --git a/docs-linhay/memory/2026-05-29.md b/docs-linhay/memory/2026-05-29.md new file mode 100644 index 00000000..98e35e30 --- /dev/null +++ b/docs-linhay/memory/2026-05-29.md @@ -0,0 +1,15 @@ +# 2026-05-29 + +## 账号卡身份模型迁移 + +- 决策:GetTokens 本地产品模型不引入用户实体,只有账号卡;`account_key/account_id` 是账号卡唯一业务身份。 +- 决策:账号登录、新增、导入、复制创建新账号卡;重新登录和编辑当前凭证更新原账号卡并保留原 ID。 +- 决策:`auth-id/auth-index/provider/attribution_key/email/API key hash` 都是 runtime evidence,不允许作为 rate-limit 策略匹配键。 +- 进展:sidecar fork 已让 Codex API key、auth-file、OpenAI-compatible provider 的 runtime auth 携带 `AccountKey`。 +- 进展:sidecar standalone Codex API key 缺失 `local-id` 时会生成 `codex-api-key:legacy-*` 并写回配置。 +- 进展:sidecar rate-limit schema/API/evaluator 破坏性删除 `match_key`,usage 查询只按 `account_key`。 +- 进展:GetTokens Wails / frontend rate-limit DTO 删除 `matchKey`,`RateLimitRulesSection` 改为单行摘要 + 配置态,无横向滚动表格。 +- 进展:账号详情 rate-limit CRUD 改为由页面 shell 注入,browser preview 不再直接触发真实 Wails binding。 +- 验证:Sidecar 聚焦 Go 测试、GetTokens Go 测试、frontend `npm run typecheck`、`npm run build`、`npm run test:unit` 均通过。 +- 验证:已用 `playwright-cli` 验收账号详情限流区 summary / config 态,确认无 Wails binding 崩溃并归档截图。 +- 验证:已用当前 GetTokens worktree 构建产物加载当前 sidecar worktree,dev sidecar `/healthz` 200;rate-limit management API 的 strategies/create/status/events/delete/list 链路均 200,临时规则已清理。 diff --git a/docs-linhay/spaces/20260529-account-card-identity-migration/README.md b/docs-linhay/spaces/20260529-account-card-identity-migration/README.md new file mode 100644 index 00000000..bf3057e1 --- /dev/null +++ b/docs-linhay/spaces/20260529-account-card-identity-migration/README.md @@ -0,0 +1,102 @@ +# 账号卡身份模型迁移 + +## 背景 + +当前账号池存在两套身份语义: + +1. GetTokens 前端账号卡使用 `AccountRecord.id`,例如 `codex-api-key:`。 +2. sidecar 请求热路径和 usage attribution 使用 `auth-id` / `auth-index` / `provider` 等 runtime evidence。 + +这种分层在展示上可以通过 Wails join 兜底,但在 rate-limit 这种需要稳定策略匹配的能力上会产生错配:规则绑定账号卡 ID,实际用量事件可能只写 runtime evidence,导致账号卡下的单日限量规则用量显示为 0。 + +本次迁移把产品模型收敛为:**没有用户实体,只有账号卡;账号卡 ID 是所有业务归属的唯一身份源**。 + +## 目标 + +1. `account_key` / `account_id` 唯一代表一张账号卡。 +2. 登录/导入/复制创建新账号卡并分配新 `account_key`。 +3. 重新登录从现有账号卡发起,只更新凭证和 runtime evidence,保留原 `account_key`。 +4. sidecar runtime auth candidate 必须携带账号卡 `account_key`。 +5. usage attribution 新事件直接写入 `account_key`;`attribution_key` 只作为诊断 evidence。 +6. rate-limit 规则、状态、事件只按 `account_key` 匹配,破坏性移除 `match_key`。 + +## 范围 + +### GetTokens App + +- Wails / management client rate-limit DTO 删除 `matchKey`。 +- 前端 `RateLimitRulesSection` 只保存 `accountKey`,保留单行摘要 + 编辑配置态。 +- 账号卡文档和测试补充“登录创建卡,重新登录更新卡”的身份语义。 + +### CLIProxyAPI Sidecar Fork + +- `config.CodexKey` 增加并持久化 `local-id`。 +- runtime `auth.Auth` 增加 `AccountKey`。 +- watcher synthesizer 为 Codex API key、auth-file、OpenAI-compatible provider 填充账号卡 ID。 +- usage attribution 新事件必须尽可能写入 `account_key`。 +- rate-limit SQLite schema/API/DTO/evaluator 破坏性移除 `match_key`。 + +## 非目标 + +- 不兼容旧版本前端或旧 sidecar API。 +- 不保留 rate-limit 表中的 `match_key` 数据。 +- 不引入服务端多用户体系;OAuth subject / email / API key hash 都只是账号卡 evidence。 +- 不把 SQLite `rowid` / 自增 ID 作为产品身份。 + +## 验收标准 + +1. sidecar runtime 中每个 GetTokens 管理的 auth candidate 都带稳定 `account_key`。 +2. 两张账号卡即使凭证内容相同,也拥有不同 `account_key`,usage 和 rate-limit 不串账。 +3. 重新登录账号卡后 `account_key` 不变,旧/new runtime evidence 都归属同一账号卡。 +4. 新 `usage_attribution_events` 对 GetTokens 管理账号必须写入非空 `account_key`。 +5. `rate_limit_rules` / `rate_limit_events` schema 不再包含 `match_key`。 +6. sidecar rate-limit evaluator 查询 usage 时只使用 `account_key = ?`。 +7. GetTokens frontend / Wails / API 类型不再出现 rate-limit `matchKey`。 +8. 文档和 memory 写入本次身份模型决策,并完成 `qmd update && qmd embed`。 + +## 设计稿入口 + +- 本期设计稿:`(未产出)` +- 约束:单期只保留一个 HTML 文件;若存在多稿对比,也必须收敛在同一个 HTML 文件内。 + +## Worktree 映射 + +- branch:`feat/20260529-account-card-identity-migration` +- worktree:`../GetTokens-worktrees/20260529-account-card-identity-migration/` +- sidecar branch:`gettokens/account-card-identity-migration` +- sidecar worktree:`../CLIProxyAPI-worktrees/20260529-account-card-identity-migration/` + +## 相关链接 + +- 既有限流 space:`docs-linhay/spaces/20260515-rate-limit-middleware/` +- sidecar usage attribution 架构:`docs-linhay/dev/20260514-sidecar-usage-account-attribution-architecture.md` +- 账号详情 runtime 观测边界:`docs-linhay/dev/20260519-account-detail-runtime-observability-boundary.md` + +## 当前状态 +- 状态:implemented-in-worktrees +- 最近更新:2026-05-29 + +## 2026-05-29 执行结果 + +- Sidecar fork 已接入 `Auth.AccountKey`,Codex API key 使用 `local-id`,auth-file 使用 `auth-file:`,OpenAI-compatible 使用 `openai-compatible:`。 +- Sidecar fork 对缺失 `local-id` 的 standalone Codex API key 会生成 `codex-api-key:legacy-*` 并写回配置。 +- Sidecar usage attribution 写入 `account_key`,rate-limit schema / evaluator / API 已破坏性移除 `match_key`。 +- GetTokens Wails / cliproxyapi / frontend rate-limit DTO 已移除 `matchKey`。 +- `RateLimitRulesSection` 默认展示单行摘要,点击编辑进入配置态;配置态不再使用横向滚动表格。 +- 账号详情弹窗的 rate-limit CRUD 改为由页面 shell 注入;browser preview 不再直接触发真实 Wails binding。 +- 登录语义保持为:新登录产生新账号卡;从账号详情发起重新登录时回填到原 auth-file 名称,因此保留原账号卡 ID。 + +## 当前验证 + +- Sidecar:`go test ./internal/config ./internal/watcher/synthesizer ./internal/gettokenshooks ./internal/runtime/executor/helps ./sdk/cliproxy/usage ./sdk/cliproxy/auth ./internal/api/handlers/management` +- GetTokens:`go test ./internal/sidecar ./internal/cliproxyapi ./internal/wailsapp` +- GetTokens frontend:`npm run typecheck` +- GetTokens frontend:`npm run build` +- GetTokens frontend:`npm run test:unit` +- Build smoke:`CLI_PROXY_SOURCE_DIR=../CLIProxyAPI-worktrees/20260529-account-card-identity-migration ./scripts/wails-cli.sh build`,确认打包产物使用 sidecar 提交 `3837f0a3`。 +- Dev runtime smoke:以 `GETTOKENS_APP_PROFILE=dev` 启动构建产物,sidecar 监听 `18317`,`/healthz` 返回 200。 +- Rate-limit management smoke:对 dev sidecar 执行 `strategies -> create rule -> status -> events -> delete rule -> list`,状态码均为 200,临时规则 `runtime-smoke-delete-me` 已删除。 +- `playwright-cli` preview:打开 `?preview=accounts#frame=accounts&detail=codex-api-key%3Astable-001`,确认无 `Cannot read properties of undefined`,summary 为单行,点击编辑进入配置表单态。 +- 截图: + - `docs-linhay/spaces/20260529-account-card-identity-migration/screenshots/20260529/accounts/20260529-accounts-rate-limit-summary-after-v01.png` + - `docs-linhay/spaces/20260529-account-card-identity-migration/screenshots/20260529/accounts/20260529-accounts-rate-limit-config-after-v01.png` diff --git a/docs-linhay/spaces/20260529-account-card-identity-migration/plans/account-card-identity-migration-v01.md b/docs-linhay/spaces/20260529-account-card-identity-migration/plans/account-card-identity-migration-v01.md new file mode 100644 index 00000000..3a4796e3 --- /dev/null +++ b/docs-linhay/spaces/20260529-account-card-identity-migration/plans/account-card-identity-migration-v01.md @@ -0,0 +1,180 @@ +# 账号卡身份模型迁移执行计划 v01 + +## 核心决策 + +1. 本地产品实体只有账号卡,没有用户实体。 +2. `account_key` / `account_id` 是账号卡的唯一业务身份。 +3. `auth-id` / `auth-index` / `source_hash` / `provider` / OAuth subject / email / API key hash 只作为 runtime evidence。 +4. rate-limit 是账号卡资产级策略,只能以 `account_key` 为匹配键。 +5. 本次不兼容旧版本:迁移后移除 rate-limit `match_key` schema、DTO、API 和前端字段。 + +## 数据模型 + +### Sidecar Config + +`config.CodexKey` 增加: + +```go +LocalID string `yaml:"local-id,omitempty" json:"local-id,omitempty"` +``` + +规则: + +- 账号登录 / 新增 key:创建新账号卡,生成新 `local-id`。 +- 重新登录 / 编辑当前卡凭证:保留原 `local-id`。 +- standalone sidecar 发现缺失 `local-id` 的 codex key:启动或保存时生成并持久化。 + +### Runtime Auth + +`sdk/cliproxy/auth.Auth` 增加: + +```go +AccountKey string `json:"account_key,omitempty"` +``` + +runtime candidate 生成规则: + +- Codex API key:`config.CodexKey.LocalID` +- auth-file:`auth-file:` +- OpenAI-compatible:`openai-compatible:` + +### SQLite + +`usage_attribution_events` 保留: + +- `account_key`:业务归属,必须优先写入。 +- `attribution_key`:运行态证据,仅用于诊断和迁移回填。 + +`rate_limit_rules` 删除: + +- `match_key` +- `idx_rate_limit_rules_match` + +`rate_limit_events` 删除: + +- `match_key` + +## 执行阶段 + +### 阶段 1:红灯测试(已完成) + +Sidecar tests: + +1. `CodexKey` 缺失 `local-id` 时,synthesizer/runtime auth 必须失败当前新测试。 +2. `Auth` 缺失 `AccountKey` 时,runtime identity 测试失败。 +3. rate-limit schema 仍含 `match_key` 的结构测试失败。 +4. evaluator 仍使用 `attribution_key` fallback 的源码结构测试失败。 + +GetTokens tests: + +1. 前端 rate-limit 源码不允许出现 `matchKey` 的测试先失败。 +2. Wails / cliproxyapi DTO 不允许出现 `MatchKey` 的测试先失败。 + +### 阶段 2:sidecar identity foundation(已完成) + +修改: + +- `internal/config/config.go` +- `internal/api/handlers/management/config_lists.go` +- `internal/watcher/synthesizer/config.go` +- `sdk/cliproxy/auth/types.go` +- 必要时补充 auth-file / OpenAI-compatible synthesizer 的 `AccountKey` + +验收: + +- `go test ./internal/config ./internal/api/handlers/management ./internal/watcher/synthesizer ./sdk/cliproxy/auth` + +### 阶段 3:usage attribution account_key 写入(已完成) + +修改: + +- `internal/gettokenshooks/usage_attribution.go` +- `internal/gettokenshooks/usage_attribution_test.go` + +验收: + +- GetTokens 管理账号的新 usage event 必须有 `account_key`。 +- unresolved event 保留 `attribution_key`,但不参与账号卡策略。 + +### 阶段 4:rate-limit 破坏性清理(已完成) + +修改: + +- `internal/gettokenshooks/rate_limit.go` +- `internal/gettokenshooks/rate_limit_test.go` + +验收: + +- `PRAGMA table_info(rate_limit_rules)` 不含 `match_key`。 +- `PRAGMA table_info(rate_limit_events)` 不含 `match_key`。 +- usage 查询只按 `account_key = ?`。 +- 两个相同凭证不同账号卡的用量和规则互不影响。 + +### 阶段 5:GetTokens bridge/frontend 清理(已完成) + +修改: + +- `internal/cliproxyapi/types.go` +- `internal/cliproxyapi/client_test.go` +- `internal/wailsapp/rate_limit_test.go` +- `app_types.go` +- `app_mappers.go` +- `frontend/src/features/accounts/model/rateLimit.ts` +- `frontend/src/features/accounts/components/RateLimitRulesSection.tsx` +- `frontend/src/features/accounts/tests/rateLimit.test.mjs` + +验收: + +- 前端和 Wails rate-limit 类型不再出现 `matchKey` / `MatchKey`。 +- UI 保持单行摘要 + 编辑配置态,不再横向滚动。 +- 账号详情弹窗不直接 import Wails rate-limit CRUD;由 Accounts/Codex page shell 根据 desktop/preview 注入 `RateLimitRulesAPI`。 + +### 阶段 6:文档、memory、索引(已完成) + +修改: + +- 本 space README / plan +- `docs-linhay/dev/account-card-identity-model.md` +- `docs-linhay/memory/2026-05-29.md` + +命令: + +```bash +docs-linhay/scripts/check-docs.sh +qmd update +qmd embed +``` + +## 验证命令 + +Sidecar: + +```bash +go test ./internal/gettokenshooks +go test ./internal/config ./internal/api/handlers/management ./internal/watcher/synthesizer ./sdk/cliproxy/auth +``` + +GetTokens: + +```bash +go test ./internal/cliproxyapi ./internal/wailsapp +cd frontend && node --test src/features/accounts/tests/rateLimit.test.mjs +cd frontend && npm run typecheck +docs-linhay/scripts/check-docs.sh +qmd update && qmd embed +``` + +## 风险 + +1. WebSocket pinned auth 可能存在独立路径,必须确认同样携带 `AccountKey`。 +2. 破坏性迁移会丢弃无法归属账号卡的旧 rate-limit 规则;这是本次明确接受的行为。 + +## 执行记录 + +- 2026-05-29:sidecar runtime auth 已覆盖 Codex API key、auth-file、OpenAI-compatible provider 的 `AccountKey`。 +- 2026-05-29:sidecar standalone Codex API key 缺失 `local-id` 时会生成 `codex-api-key:legacy-*` 并写回配置。 +- 2026-05-29:sidecar rate-limit schema/API/evaluator 删除 `match_key`,只按 `account_key` 查询 usage。 +- 2026-05-29:GetTokens Wails / frontend rate-limit DTO 删除 `matchKey`,规则区改为单行摘要 + 配置态。 +- 2026-05-29:修复 browser preview 根因,`UnifiedAccountDetailModal` / `CodexAccountDetailModal` 不再直连真实 Wails rate-limit CRUD。 +- 2026-05-29:已运行 Sidecar 聚焦 Go 测试、GetTokens Go 测试、frontend `typecheck`、`build` 和 `test:unit`。 +- 2026-05-29:已用 `playwright-cli` 验收账号详情限流区 summary / config 态,并归档截图。 diff --git a/frontend/package.json b/frontend/package.json index 209cf532..6f9360f9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "test:unit": "node --test src/utils/version.test.mjs src/utils/pagePersistence.test.mjs src/context/TextScaleContext.test.mjs src/tests/legacyFilterGuard.test.mjs src/features/settings/settingsBuildMetadata.test.mjs src/features/settings/settingsRelease.test.mjs src/features/settings/settingsLocalUsage.test.mjs src/features/settings/settingsTextScale.test.mjs src/features/settings/settingsLayout.test.mjs src/features/design-system/storyCatalog.test.mjs src/features/accounts/tests/accountConfig.test.mjs src/features/accounts/tests/accountDelete.test.mjs src/features/accounts/tests/accountDeleteOverlay.test.mjs src/features/accounts/tests/accountDisabledSync.test.mjs src/features/accounts/tests/accountEvidence.test.mjs src/features/accounts/tests/accountFilters.test.mjs src/features/accounts/tests/accountQuotaCache.test.mjs src/features/accounts/tests/accountSnapshot.test.mjs src/features/accounts/tests/accountSelectors.test.mjs src/features/accounts/tests/accountSelection.test.mjs src/features/accounts/tests/accountTransfer.test.mjs src/features/accounts/tests/accountPresentation.test.mjs src/features/accounts/tests/accountOAuth.test.mjs src/features/accounts/tests/accountCardInteractions.test.mjs src/features/accounts/tests/accountCardLayout.test.mjs src/features/accounts/tests/accountHeaderMenu.test.mjs src/features/accounts/tests/accountDetailLayout.test.mjs src/features/accounts/tests/accountHealthMeta.test.mjs src/features/accounts/tests/accountInventoryBoundary.test.mjs src/features/accounts/tests/accountRotation.test.mjs src/features/accounts/tests/accountProxyRoute.test.mjs src/features/accounts/tests/apiKeyModelCatalog.test.mjs src/features/accounts/tests/accountListLayout.test.mjs src/features/accounts/tests/accountUsage.test.mjs src/features/accounts/tests/usageDesk.test.mjs src/features/accounts/tests/usageDeskClaudeLocalSource.test.mjs src/features/accounts/tests/openAICompatible.test.mjs src/features/accounts/tests/accountLocalCliMapping.test.mjs src/features/channel-routing/tests/channelRouting.test.mjs src/features/codex/codexAccountList.test.mjs src/features/codex-live-sessions/model.test.mjs src/features/claude-code/claudeCodeAccountList.test.mjs src/features/claude-code/claudeCodeAssetWorkbench.test.mjs src/features/codex-binary/model.test.mjs src/features/codex-extensions/model.test.mjs src/features/proxy-pool/model.test.mjs src/features/session-management/model.test.mjs src/features/session-management/cache.test.mjs src/features/status/tests/codexFeatureConfig.test.mjs src/features/status/tests/relayLocalState.test.mjs src/features/status/tests/statusTypography.test.mjs src/components/biz/accountDetailClipboard.test.mjs src/components/biz/sidebarState.test.mjs src/components/biz/sidebarUpdatePrompt.test.mjs" + "test:unit": "node --test src/utils/version.test.mjs src/utils/pagePersistence.test.mjs src/context/TextScaleContext.test.mjs src/tests/legacyFilterGuard.test.mjs src/features/settings/settingsBuildMetadata.test.mjs src/features/settings/settingsRelease.test.mjs src/features/settings/settingsLocalUsage.test.mjs src/features/settings/settingsTextScale.test.mjs src/features/settings/settingsLayout.test.mjs src/features/design-system/storyCatalog.test.mjs src/features/accounts/tests/accountConfig.test.mjs src/features/accounts/tests/accountDelete.test.mjs src/features/accounts/tests/accountDeleteOverlay.test.mjs src/features/accounts/tests/accountDisabledSync.test.mjs src/features/accounts/tests/accountEvidence.test.mjs src/features/accounts/tests/accountFilters.test.mjs src/features/accounts/tests/accountQuotaCache.test.mjs src/features/accounts/tests/accountSnapshot.test.mjs src/features/accounts/tests/accountSelectors.test.mjs src/features/accounts/tests/accountSelection.test.mjs src/features/accounts/tests/accountTransfer.test.mjs src/features/accounts/tests/accountPresentation.test.mjs src/features/accounts/tests/accountOAuth.test.mjs src/features/accounts/tests/accountCardInteractions.test.mjs src/features/accounts/tests/accountCardLayout.test.mjs src/features/accounts/tests/accountHeaderMenu.test.mjs src/features/accounts/tests/accountDetailLayout.test.mjs src/features/accounts/tests/rateLimit.test.mjs src/features/accounts/tests/accountHealthMeta.test.mjs src/features/accounts/tests/accountInventoryBoundary.test.mjs src/features/accounts/tests/accountRotation.test.mjs src/features/accounts/tests/accountProxyRoute.test.mjs src/features/accounts/tests/apiKeyModelCatalog.test.mjs src/features/accounts/tests/accountListLayout.test.mjs src/features/accounts/tests/accountUsage.test.mjs src/features/accounts/tests/usageDesk.test.mjs src/features/accounts/tests/usageDeskClaudeLocalSource.test.mjs src/features/accounts/tests/openAICompatible.test.mjs src/features/accounts/tests/accountLocalCliMapping.test.mjs src/features/channel-routing/tests/channelRouting.test.mjs src/features/codex/codexAccountList.test.mjs src/features/codex-live-sessions/model.test.mjs src/features/claude-code/claudeCodeAccountList.test.mjs src/features/claude-code/claudeCodeAssetWorkbench.test.mjs src/features/codex-binary/model.test.mjs src/features/codex-extensions/model.test.mjs src/features/proxy-pool/model.test.mjs src/features/session-management/model.test.mjs src/features/session-management/cache.test.mjs src/features/status/tests/codexFeatureConfig.test.mjs src/features/status/tests/relayLocalState.test.mjs src/features/status/tests/statusTypography.test.mjs src/components/biz/accountDetailClipboard.test.mjs src/components/biz/sidebarState.test.mjs src/components/biz/sidebarUpdatePrompt.test.mjs" }, "dependencies": { "lucide-react": "^1.11.0", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 60ec56c9..6eb749b9 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -a4a7e56ebeba0fcb8ce62f8621bee701 \ No newline at end of file +16af913a374338e03d3efe17f9404df8 \ No newline at end of file diff --git a/frontend/src/features/accounts/AccountsFeature.tsx b/frontend/src/features/accounts/AccountsFeature.tsx index c9040fa1..29fdb042 100644 --- a/frontend/src/features/accounts/AccountsFeature.tsx +++ b/frontend/src/features/accounts/AccountsFeature.tsx @@ -1054,6 +1054,14 @@ export default function AccountsFeature({ workspace }: AccountsFeatureProps) { usageSummary={accountUsageByID[selectedAccount.id]} rateLimitStatus={accountRateLimitByID[selectedAccount.id]} rateLimitStrategies={rateLimitStrategies} + rateLimitRulesAPI={previewMode + ? undefined + : { + list: ListRateLimitRules, + create: CreateRateLimitRule, + update: UpdateRateLimitRule, + delete: DeleteRateLimitRule, + }} verifyState={apiKeyVerifyState} modelNames={relayModelNames} onClose={closeAccountDetail} @@ -1078,12 +1086,14 @@ export default function AccountsFeature({ workspace }: AccountsFeatureProps) { usageSummary={accountUsageByID[`openai-compatible:${openAICompatibleState.detailDraft.currentName}`]} rateLimitStatus={accountRateLimitByID[`openai-compatible:${openAICompatibleState.detailDraft.currentName}`]} rateLimitStrategies={rateLimitStrategies} - rateLimitRulesAPI={{ - list: ListRateLimitRules, - create: CreateRateLimitRule, - update: UpdateRateLimitRule, - delete: DeleteRateLimitRule, - }} + rateLimitRulesAPI={previewMode + ? undefined + : { + list: ListRateLimitRules, + create: CreateRateLimitRule, + update: UpdateRateLimitRule, + delete: DeleteRateLimitRule, + }} verifyState={ openAICompatibleState.verifyStates[openAICompatibleState.detailDraft.currentName] ?? { model: openAICompatibleState.detailDraft.verifyModel, diff --git a/frontend/src/features/accounts/components/AccountModalComponents.stories.tsx b/frontend/src/features/accounts/components/AccountModalComponents.stories.tsx index 7f74a14a..4c453238 100644 --- a/frontend/src/features/accounts/components/AccountModalComponents.stories.tsx +++ b/frontend/src/features/accounts/components/AccountModalComponents.stories.tsx @@ -335,7 +335,6 @@ const apiKeyUsageSummary: AccountUsageSummary = { const apiKeyRateLimitStatus: RateLimitState = { accountKey: 'codex-api-key-preview', - matchKey: 'codex-api-key-preview', blocked: false, rules: [ { diff --git a/frontend/src/features/accounts/components/ApiKeyDetailModal.tsx b/frontend/src/features/accounts/components/ApiKeyDetailModal.tsx index aa4327da..e66f6ed6 100644 --- a/frontend/src/features/accounts/components/ApiKeyDetailModal.tsx +++ b/frontend/src/features/accounts/components/ApiKeyDetailModal.tsx @@ -526,7 +526,6 @@ export default function ApiKeyDetailModal({ (null); const rateLimitAccountName = draft.currentName || draft.name; const rateLimitAccountKey = `openai-compatible:${rateLimitAccountName}`; - const rateLimitMatchKey = rateLimitStatus?.matchKey || `provider:${rateLimitAccountName.trim().toLowerCase()}`; useEffect(() => { setRateLimitDirty(false); @@ -116,7 +115,6 @@ export default function OpenAICompatibleDetailModal({ undefined} diff --git a/frontend/src/features/accounts/components/RateLimitRulesSection.tsx b/frontend/src/features/accounts/components/RateLimitRulesSection.tsx index 8cb9fd2c..4d9c4652 100644 --- a/frontend/src/features/accounts/components/RateLimitRulesSection.tsx +++ b/frontend/src/features/accounts/components/RateLimitRulesSection.tsx @@ -31,7 +31,6 @@ import { interface RateLimitRulesSectionProps { accountKey: string; - matchKey?: string; rateLimitStatus?: RateLimitState; rateLimitStrategies?: RateLimitStrategyMeta[]; rateLimitRulesAPI?: RateLimitRulesAPI; @@ -52,13 +51,9 @@ export interface RateLimitRulesAPI { delete: (input: { id: string }) => Promise; } -const ROW_GRID_CLASS = - 'grid grid-cols-[10rem_13rem_9rem_10rem_8rem_6rem_10rem_5rem] items-center gap-2'; - const RateLimitRulesSection = forwardRef(function RateLimitRulesSection( { accountKey, - matchKey, rateLimitStatus, rateLimitStrategies, rateLimitRulesAPI, @@ -75,12 +70,17 @@ const RateLimitRulesSection = forwardRef('danger'); const [savingRules, setSavingRules] = useState(false); + const [rateLimitViewMode, setRateLimitViewMode] = useState<'summary' | 'config'>('summary'); const dirtyRef = useRef(false); const dirty = useMemo( () => deletedRuleIDs.length > 0 || serializeRateLimitRules(ruleDrafts) !== serializeRateLimitRules(baselineRuleDrafts), [baselineRuleDrafts, deletedRuleIDs, ruleDrafts], ); + const rateLimitSummaryText = useMemo( + () => buildRateLimitSummaryText(ruleDrafts, rateLimitStatus, t), + [rateLimitStatus, ruleDrafts, t], + ); useEffect(() => { dirtyRef.current = dirty; @@ -142,7 +142,6 @@ const RateLimitRulesSection = forwardRef draft.id).map((draft) => [draft.id, draft])); + const baselineByID = new Map( + baselineRuleDrafts + .filter((draft): draft is RateLimitRule & { id: string } => Boolean(draft.id)) + .map((draft) => [draft.id, draft]), + ); for (const id of deletedRuleIDs) { await rateLimitRulesAPI.delete({ id }); } @@ -206,6 +211,7 @@ const RateLimitRulesSection = forwardRef - {t('accounts.rate_limit_add_rule')} - +
+ {rateLimitViewMode === 'config' ? ( + + ) : null} + +
} >
@@ -257,23 +275,37 @@ const RateLimitRulesSection = forwardRef ) : null} - {ruleDrafts.length === 0 ? ( - - {t('accounts.rate_limit_no_local_rule')} - + {rateLimitViewMode === 'summary' ? ( +
+ +
+ ) : ruleDrafts.length === 0 ? ( +
+ + + +
) : ( -
-
-
-
{t('accounts.rate_limit_label')}
-
{t('accounts.rate_limit_strategy')}
-
{t('accounts.rate_limit_window')}
-
{t('accounts.rate_limit_limit')}
-
{t('accounts.rate_limit_action')}
-
{t('accounts.rate_limit_enabled_short')}
-
{t('accounts.rate_limit_not_evaluated')}
-
-
+
{ruleDrafts.map((draft, index) => { const strategy = strategies.find((item) => item.id === draft.strategy) || strategies[0]; const windows = supportedWindowsForStrategy(strategy); @@ -282,14 +314,21 @@ const RateLimitRulesSection = forwardRef {t('accounts.rate_limit_rule_legend')} -
- {rateLimitRuleLabel(draft)} -
+ + updateRateLimitDraft(index, { label: event.target.value })} + className="input-swiss h-9 w-full !py-1 !text-[length:var(--font-size-ui-xs)]" + /> + -
+
+
+ {t('accounts.rate_limit_enabled_short')} +
deleteRateLimitRule(index)} - className="btn-swiss h-9 !px-2 !py-1 !text-[length:var(--font-size-ui-2xs)] !text-[var(--color-status-danger)]" + className="btn-swiss h-9 self-end !px-2 !py-1 !text-[length:var(--font-size-ui-2xs)] !text-[var(--color-status-danger)] xl:self-center" disabled={savingRules} > {t('common.delete')} @@ -386,7 +428,6 @@ const RateLimitRulesSection = forwardRef ); })} -
)}
@@ -400,7 +441,6 @@ function normalizeRateLimitRuleDraft(rule: RateLimitRule, accountKey: string): R return { id: String(rule?.id || '').trim() || undefined, accountKey: String(rule?.accountKey || accountKey).trim(), - matchKey: String(rule?.matchKey || '').trim(), strategy: String(rule?.strategy || DEFAULT_RATE_LIMIT_STRATEGIES[0].id).trim(), window: String(rule?.window || '24h').trim(), limitValue: Math.max(0, Number(rule?.limitValue || 0)), @@ -444,7 +484,6 @@ function compactRateLimitRule(rule: RateLimitRule) { return { id: String(rule.id || ''), accountKey: String(rule.accountKey || ''), - matchKey: String(rule.matchKey || ''), strategy: String(rule.strategy || ''), window: String(rule.window || ''), limitValue: Math.max(0, Number(rule.limitValue || 0)), @@ -454,10 +493,38 @@ function compactRateLimitRule(rule: RateLimitRule) { }; } +function buildRateLimitSummaryText( + rules: RateLimitRule[], + status: RateLimitState | undefined, + t: Translator, +) { + if (rules.length === 0) { + return t('accounts.rate_limit_no_local_rule'); + } + const enabledCount = rules.filter((rule) => rule.enabled).length; + const evaluatedRules = status?.rules ?? []; + const exceededCount = evaluatedRules.filter((item) => item.exceeded).length; + const hottestRule = evaluatedRules.reduce<(typeof evaluatedRules)[number] | undefined>((current, item) => { + if (!current || item.usagePct > current.usagePct) { + return item; + } + return current; + }, undefined); + const usageText = hottestRule + ? `${Math.round(hottestRule.usagePct)}% ${formatRateLimitMetric(hottestRule.currentUsage)}/${formatRateLimitMetric(hottestRule.rule.limitValue)}` + : t('accounts.rate_limit_not_evaluated'); + const statusText = status?.blocked + ? `${t('accounts.rate_limit_summary_blocked')} ${Math.max(exceededCount, 1)}` + : t('accounts.rate_limit_summary_enabled'); + return `${statusText} / ${t('accounts.rate_limit_summary_rules')} ${rules.length} / ${t('accounts.rate_limit_summary_active')} ${enabledCount} / ${usageText}`; +} + function RuleField({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) { return ( -