diff --git a/docs/2026-05-21-BATCH_AUTO_IMPORT_SPEC.md b/docs/2026-05-21-BATCH_AUTO_IMPORT_SPEC.md index af0612f5..602b6a3e 100644 --- a/docs/2026-05-21-BATCH_AUTO_IMPORT_SPEC.md +++ b/docs/2026-05-21-BATCH_AUTO_IMPORT_SPEC.md @@ -14,6 +14,7 @@ V2 的目标不是“又一条导入命令”,而是把这件事做成**稳定 5. **异步确认**:吸收宿主异步 probe、首次 `403/503` 的预热窗口 6. **闭环验证**:以宿主网关真实 `/v1/chat/completions` 结果作为最终可用性判断 7. **结果可视**:提供 run 列表、run 详情、item 详情,而不是只靠日志和 artifact +8. **重复导入复用**:已成功导入且模型已覆盖的 provider,再次添加时应自动复用,而不是重复创建 ## 2. Scope @@ -166,14 +167,18 @@ BatchImportRunItemView - item_id: string - base_url: string - provider_id: string + - api_key_fingerprint: string - requested_models: []string - raw_models: []string - normalized_models: []string + - canonical_model_families: []string - resolved_smoke_model: string | null - recommended_models: []string - current_stage: string - confirmation_status: string - access_status: string + - matched_account_state: string + - account_resolution: string - retry_count: int - last_retry_at: string | null - advisory_messages: []string @@ -181,6 +186,9 @@ BatchImportRunItemView - last_error: string | null - channel_id: int64 | null - account_id: int64 | null + - provision_reused: bool + - reused_from_provider_id: string | null + - reused_from_account_id: int64 | null - capability_profile: object ``` @@ -235,6 +243,22 @@ Stage 5: Project 1. **transport profile**:这个 upstream 支不支持 `/models`、`/chat/completions`、`/responses`、`/messages` 2. **model profiles**:这个 upstream 下的具体模型,在 stream/tools/reasoning 字段上是否可用 +### 6.1.1 为什么还要有 canonical model family + +不同中转对同一个模型的命名可能有轻微差异,但 API 和能力集本质一致,例如: + +- `kimi 2.6` +- `kimi-2.6` +- `kimi-k2.6` +- `Kimi-K2.6` + +V2 不能把这些名字当成完全不同的模型,而要继续归并到同一个 `canonical_model_family`,用于: + +- 重复导入复用判断 +- 模型覆盖判断 +- 别名 patch 判断 +- 推荐模型名输出 + ### 6.2 Canonical schema ```json @@ -255,6 +279,7 @@ Stage 5: Project { "raw_model_id": "deepseek-ai/DeepSeek-V4-Pro", "normalized_model_id": "deepseek-v4-pro", + "canonical_model_family": "deepseek-v4-pro", "supports_stream": true, "supports_tools": "unknown", "supports_reasoning_fields": "unknown", @@ -272,7 +297,109 @@ Stage 5: Project - 决定推荐 smoke model - 决定后续快速匹配“哪个模型在哪种兼容层下靠谱” -## 7. Channel / Account Evolution Contract +### 6.4 Canonical model family 规则 + +V2 对模型名做三层处理: + +1. `raw_model_id` +2. `normalized_model_id` +3. `canonical_model_family` + +示例: + +| raw_model_id | normalized_model_id | canonical_model_family | +|---|---|---| +| `kimi 2.6` | `kimi-2.6` | `kimi-2.6` | +| `kimi-k2.6` | `kimi-k2.6` | `kimi-2.6` | +| `Kimi-K2.6` | `kimi-k2.6` | `kimi-2.6` | +| `deepseek-ai/DeepSeek-V4-Pro` | `deepseek-v4-pro` | `deepseek-v4-pro` | + +约束: + +- `canonical_model_family` 用于跨中转识别“是否同一个模型族” +- `normalized_model_id` 用于控制面和 channel 落盘 +- `raw_model_id` 用于保留 upstream 原始路由 + +## 7. Existing Provider Reuse / Idempotent Re-import + +### 7.1 目标 + +如果某个 provider 已成功导入,且现有模型族已覆盖本次请求模型,则再次添加时应: + +- 不重复创建 channel/account/provider +- 直接复用既有成功链路 +- 必要时仅 patch 新 alias / 新模型映射 + +### 7.2 预检查顺序 + +每个 item 在 Stage 2 前必须按顺序执行: + +1. 按 `host_id + provider_id` 查现有 provider +2. 按 `host_id + base_url + api_key_fingerprint` 查现有 account +3. 比较: + - `canonical_model_families` + - `normalized_models` + - 既有 `access_status` + - 既有账号健康状态 + +### 7.3 决策表 + +| 场景 | 行为 | +|---|---| +| provider 已存在,`access_status=active`,且既有 `canonical_model_families` 覆盖本次请求 | 直接复用,不再 provision | +| 命中现有 account,且账号状态为 `active` | 标记为重复已启用账号,直接复用并提示 `duplicate_active_account` | +| 命中现有 account,且账号状态为 `disabled` 或 `deprecated`,但 key 仍健康 | 走 `reactivated` 路径,快速启用已有账号,不新建账号 | +| provider 已存在,账号健康,但只缺少部分 alias / mapping | 只 patch,不重建 | +| provider 已存在,但 key 已失效或 `access_status=broken` | 不复用,进入 repair/replace | +| 同 host 同 URL,但 access_mode 不同 | 不直接复用 access 结果,按 mode 分别确认 | + +### 7.4 复用后的 item 投影 + +若命中复用,item 仍要生成新的 V2 记录,并写明: + +- `provision_reused = true` +- `reused_from_provider_id` +- `reused_from_account_id` +- `matched_account_state` +- `account_resolution` + +### 7.4.1 已存在账号的处理原则 + +V2 必须同时回答两件事: + +1. 这次 provider 是否被复用 +2. 命中的既有账号当前是什么状态 + +对于 `host_id + base_url + api_key_fingerprint` 命中的账号: + +- `active` + - 不重复创建账号 + - `matched_account_state=active` + - `account_resolution=reused` + - UI 文案显示“重复,已启用” +- `disabled` / `deprecated` + - 优先尝试启用已有账号 + - `matched_account_state=disabled|deprecated` + - `account_resolution=reactivated` + - UI 文案显示“已弃用,已快速启用” +- `broken` + - 不直接复用 + - `matched_account_state=broken` + - `account_resolution=replaced` + - 进入 repair/replace 流程 + +### 7.5 Key fingerprint + +V2 不以原始 key 字符串作为重复匹配依据,而保存: + +- `api_key_fingerprint` + +用于区分: + +- 同一把 key 的重复导入 +- 同 URL 下新增另一把 key + +## 8. Channel / Account Evolution Contract V2 不再使用“薄 patch 接口”表达 channel 更新。宿主 patch 必须以完整 contract 表达: @@ -291,7 +418,7 @@ ChannelPatchContract - patch 不得破坏旧模型 - `PatchChannel(addModels []string)` 这类接口不再作为 V2 canonical contract -## 8. Async Confirmation Mechanism +## 9. Async Confirmation Mechanism ### 8.1 为什么 V2 必须有后台 confirmer @@ -334,7 +461,7 @@ V2 第一版即要求: - 页面能看到 item 停在哪个阶段 - CLI `--confirm-wait-timeout` 只是“等待窗口”,不是确认机制本身 -## 9. Single Source of Truth +## 10. Single Source of Truth ### 9.1 Canonical runtime tables @@ -362,7 +489,7 @@ V2 运行态只认三类表: - `access_closure_records` - 宿主数据库 -## 10. Result API and Pages +## 11. Result API and Pages ### 10.1 API @@ -394,7 +521,7 @@ Legacy API `/api/import-batches/*` 保留,但标为 v1/legacy。 - 重试过几次 - 当前 warning 的原因是什么 -## 11. CLI Contract +## 12. CLI Contract ```bash go run ./cmd/cli batch-import \ @@ -419,7 +546,7 @@ CLI 输出必须至少包含: - `access_status` - 推荐模型名(若发生纠错) -## 12. Error Policy +## 13. Error Policy ### Blocking @@ -439,7 +566,7 @@ CLI 输出必须至少包含: - `confirmation_status=advisory` 不自动等于 `access_status=degraded` - 只有 Validation Engine 可以把 item 标成 `active/degraded/broken` -## 13. Success Criteria +## 14. Success Criteria 1. `access_mode` 输入契约完整,`subscription` / `self_service` 都可单独落地 2. run / item 状态、重试、warning、错误阶段能持久化并在重启后恢复可见 @@ -448,18 +575,21 @@ CLI 输出必须至少包含: 5. 第三方兼容 upstream 的 `/responses` 误判和宿主异步窗口不会把可用链路直接打成最终失败 6. 页面可以清楚地区分 `confirmed/advisory/failed` 与 `active/degraded/broken` 7. OpenAPI、SPEC、TDD、Architecture 对同一字段和同一状态枚举保持一致 +8. 已成功导入的 provider 再次添加时,若模型族已覆盖,应自动复用,不重复创建 +9. 同模型在不同中转下的轻微命名差异,能通过 `canonical_model_family` 快速识别为同一模型族 -## 14. Non-goals for first implementation +## 15. Non-goals for first implementation - 多 key 自动调度 - 实时推送 - 自动定价策略 - 自动负载均衡 -## 15. Final decisions +## 16. Final decisions 1. `provider_id` 采用 `normalized_host + url_hash_last8` 2. `requested_models` 仅作提示,不作为事实源 3. `Validation Engine` 是 `access_status` 唯一写入方 4. V2 runtime canonical tables 为 `import_runs/import_run_items/import_run_item_events` 5. `ConfirmationWorker` 是 V2 必备组件,不是可选增强 +6. 同模型跨中转匹配以 `canonical_model_family` 为准,而不是只看原始模型名 diff --git a/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md b/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md index 7c2d9b09..1c4a4b4c 100644 --- a/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md +++ b/docs/2026-05-21-BATCH_AUTO_IMPORT_TDD_PLAN.md @@ -11,11 +11,12 @@ 1. URL + key 自动发现模型 2. 模型名归一化与推荐纠错 -3. provider/model 兼容画像建模 -4. 宿主资源演化与 provider 绑定 -5. 后台异步确认与有限重试 -6. 最终 gateway completion 验证 -7. run/item 状态持久化与结果页可读 +3. 跨中转同模型快速匹配与复用 +4. provider/model 兼容画像建模 +5. 宿主资源演化与 provider 绑定 +6. 后台异步确认与有限重试 +7. 最终 gateway completion 验证 +8. run/item 状态持久化与结果页可读 ## 2. Canonical Contract @@ -173,6 +174,7 @@ type AliasResult struct { func NormalizeModelID(raw string) string func CanonicalModelID(raw string) string +func CanonicalModelFamily(raw string) string func BuildAliasTable(rawModels []string) map[string]AliasResult func ResolveRequestedModel(requested string, rawModels []string) (resolved string, ok bool) func RecommendModels(requested []string, rawModels []string) []string @@ -183,6 +185,7 @@ func RecommendModels(requested []string, rawModels []string) []string ```go func TestNormalizeModelID_MinimaxCanonical(t *testing.T) func TestNormalizeModelID_DeepSeekVendorPrefix(t *testing.T) +func TestCanonicalModelFamily_KimiVariantsCollapseToSameFamily(t *testing.T) func TestResolveRequestedModel_UsesNormalizedAlias(t *testing.T) func TestRecommendModels_ReturnsCanonicalCandidates(t *testing.T) ``` @@ -319,6 +322,44 @@ func TestModelMappingDelta_AddsRawToCanonicalMappings(t *testing.T) func TestModelMappingDelta_SetsRestrictModelsAndBillingSource(t *testing.T) ``` +### 5.4 `internal/batch/reuse_policy.go` + +职责:判断已存在 provider/account 是否可直接复用。 + +```go +type ReuseDecision struct { + ReuseProvision bool + PatchOnly bool + ReplaceAccount bool + ReactivateAccount bool + MatchedAccountState string + AccountResolution string + ReusedFromProviderID string + ReusedFromAccountID *int64 +} + +func DecideReuse(existing ExistingProviderSnapshot, incoming IncomingProviderSnapshot) ReuseDecision +``` + +判断依据: + +- `host_id + provider_id` +- `base_url + api_key_fingerprint` +- `canonical_model_families` +- 现有 `access_status` +- 现有 key/account 健康状态 + +单测: + +```go +func TestDecideReuse_FullyCoveredAndActive_ReusesProvision(t *testing.T) +func TestDecideReuse_MissingFamilies_PatchOnly(t *testing.T) +func TestDecideReuse_BrokenProvider_RequestsReplacement(t *testing.T) +func TestDecideReuse_SameFamilyDifferentAlias_TreatedAsCovered(t *testing.T) +func TestDecideReuse_ExistingActiveAccount_MarksDuplicateReused(t *testing.T) +func TestDecideReuse_DisabledAccount_RequestsReactivation(t *testing.T) +``` + ## 6. Stage 3: State Store ### 6.1 `internal/batch/run_state.go` @@ -351,12 +392,16 @@ type ImportRunItemState struct { ItemID string BaseURL string ProviderID string + APIKeyFingerprint string CurrentStage ItemStage ConfirmationStatus ConfirmationStatus AccessStatus AccessStatus + MatchedAccountState string + AccountResolution string RequestedModels []string RawModels []string NormalizedModels []string + CanonicalModelFamilies []string ResolvedSmokeModel *string RecommendedModels []string CapabilityProfileJSON string @@ -373,6 +418,9 @@ type ImportRunItemState struct { LastError *string LegacyBatchID *int64 LegacyProviderID *string + ProvisionReused bool + ReusedFromProviderID *string + ReusedFromAccountID *int64 CreatedAt time.Time UpdatedAt time.Time } @@ -397,6 +445,7 @@ func TestRunStateStore_CreateAndUpdateRun(t *testing.T) func TestRunStateStore_UpsertItemStoresProjectionFields(t *testing.T) func TestRunStateStore_EventTrailCanBeQueried(t *testing.T) func TestRunStateStore_LeaseFieldsPersist(t *testing.T) +func TestRunStateStore_AccountReuseFieldsPersist(t *testing.T) ``` ## 7. Stage 4: Batch Service @@ -418,6 +467,7 @@ func (s *BatchImportService) StartRun(ctx context.Context, req BatchImportRunReq 职责: - 创建 run + item +- 先执行 reuse preflight,决定是复用、patch 还是 replace - 先落 probe/provision 结果 - 入队 confirm,不在请求线程里承担全部确认责任 - CLI/HTTP 只负责“发起”和“可选等待窗口” @@ -428,6 +478,7 @@ func (s *BatchImportService) StartRun(ctx context.Context, req BatchImportRunReq func TestBatchImport_StartRun_PersistsInitialState(t *testing.T) func TestBatchImport_RequestedModelMiss_UsesDiscoveredModel(t *testing.T) func TestBatchImport_ProvisionWritesLegacyLinks(t *testing.T) +func TestBatchImport_ExistingActiveProviderAndCoveredFamilies_ReusesProvision(t *testing.T) ``` ## 8. Stage 5: Confirmation Worker diff --git a/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_API_SCHEMAS.md b/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_API_SCHEMAS.md index b9cf4382..954c453e 100644 --- a/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_API_SCHEMAS.md +++ b/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_API_SCHEMAS.md @@ -131,6 +131,8 @@ V2 API 只暴露 3 层资源: - `has_warning` - `true/false` - `provider_id` +- `matched_account_state` +- `account_resolution` - `q` - 搜索 `provider_id / base_url / item_id` - `limit` @@ -235,11 +237,16 @@ V2 API 只暴露 3 层资源: "item_id": "item_01", "base_url": "https://kimi.a7m.com.cn/v1", "provider_id": "kimi-a7m-7d7ac291", + "api_key_fingerprint": "sha256:8d8c4b5f", "requested_models": ["kimi-k2.6"], + "canonical_model_families": ["kimi-k2.6"], "resolved_smoke_model": "kimi-k2.6", "current_stage": "done", "confirmation_status": "advisory", "access_status": "active", + "matched_account_state": "active", + "account_resolution": "reused", + "provision_reused": true, "retry_count": 2, "last_retry_at": "2026-05-22T12:20:05+08:00", "advisory_messages": [ @@ -260,14 +267,21 @@ V2 API 只暴露 3 层资源: "item_id": "item_01", "base_url": "https://kimi.a7m.com.cn/v1", "provider_id": "kimi-a7m-7d7ac291", + "api_key_fingerprint": "sha256:8d8c4b5f", "requested_models": ["kimi-k2.6"], "raw_models": ["kimi-k2.6"], "normalized_models": ["kimi-k2.6"], + "canonical_model_families": ["kimi-k2.6"], "recommended_models": [], "resolved_smoke_model": "kimi-k2.6", "current_stage": "done", "confirmation_status": "advisory", "access_status": "active", + "matched_account_state": "deprecated", + "account_resolution": "reactivated", + "provision_reused": true, + "reused_from_provider_id": "kimi-a7m-7d7ac291", + "reused_from_account_id": 4, "retry_count": 2, "last_retry_at": "2026-05-22T12:20:05+08:00", "channel_id": 12, @@ -294,6 +308,7 @@ V2 API 只暴露 3 层资源: { "raw_model_id": "kimi-k2.6", "normalized_model_id": "kimi-k2.6", + "canonical_model_family": "kimi-k2.6", "supports_stream": true, "supports_tools": "unknown", "supports_reasoning_fields": "unknown", @@ -345,6 +360,31 @@ V2 API 只暴露 3 层资源: | `degraded` | 黄色 `degraded` | | `broken` | 红色 `broken` | +### 7.4 Reuse badge + +| 字段 | 页面 badge | +|---|---| +| `provision_reused=true` | 青色 `reused` | +| `provision_reused=false` | 不显示 | + +### 7.5 Account state badge + +| 字段 | 页面 badge | +|---|---| +| `matched_account_state=active` | 绿色 `已启用` | +| `matched_account_state=disabled` | 灰色 `已停用` | +| `matched_account_state=deprecated` | 黄色 `已弃用` | +| `matched_account_state=broken` | 红色 `已损坏` | + +### 7.6 Account resolution badge + +| 字段 | 页面 badge | +|---|---| +| `account_resolution=created` | 蓝色 `新建` | +| `account_resolution=reused` | 青色 `复用` | +| `account_resolution=reactivated` | 绿色 `已快速启用` | +| `account_resolution=replaced` | 红色 `已替换` | + ## 8. 分页与排序建议 ### 8.1 Runs 列表 @@ -371,6 +411,8 @@ V2 API 只暴露 3 层资源: 2. item 详情才返回 `capability_profile` 与 `events` 3. 不在 handler 里拼装 legacy 表结果 4. 任何页面要显示的 warning 文案,都从 projection 层统一生成 +5. `provision_reused`、`reused_from_provider_id` 等复用字段必须来自 projection,不允许前端自行推断 +6. `matched_account_state` 与 `account_resolution` 也必须来自 projection,不允许前端根据 account_id 是否存在自行推断 ## 10. 最小实现建议 diff --git a/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_ARCHITECTURE.md b/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_ARCHITECTURE.md index 8b953718..97493b62 100644 --- a/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_ARCHITECTURE.md +++ b/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_ARCHITECTURE.md @@ -26,6 +26,7 @@ V2 必须同时满足: 3. **可解释**:warning/broken 必须给出可读原因 4. **可分层**:Probe、Provision、Confirm、Validate 各司其职 5. **可兼容**:针对第三方 OpenAI-compatible 上游,显式记录 transport + model capability +6. **可复用**:重复导入命中同 URL + 同模型家族时,优先复用已有 provider/account,而不是重复创建 ## 3. Canonical runtime model @@ -66,6 +67,7 @@ operator input Batch Import API / CLI ↓ BatchImportService + ├── Reuse Preflight ├── Probe Layer ├── Capability Profiler ├── Provision Adapter @@ -95,6 +97,7 @@ BatchImportService 职责: +- 执行 Reuse Preflight - 执行 Stage 1 Probe - 执行 Stage 2 Provision - 把 item 推进到 `confirm` @@ -170,6 +173,21 @@ Run 级 projection 规则: - `degraded` - `broken` +`item.matched_account_state`: + +- `none` +- `active` +- `disabled` +- `deprecated` +- `broken` + +`item.account_resolution`: + +- `created` +- `reused` +- `reactivated` +- `replaced` + ### 5.3 State ownership | 字段 | 写入者 | @@ -242,6 +260,7 @@ V2 不再把 capability 当成“一个 key 一个总画像”,而是拆成: { "raw_model_id": "kimi-k2.6", "normalized_model_id": "kimi-k2.6", + "canonical_model_family": "kimi-k2.6", "supports_stream": true, "supports_tools": "unknown", "supports_reasoning_fields": "unknown", @@ -251,6 +270,22 @@ V2 不再把 capability 当成“一个 key 一个总画像”,而是拆成: } ``` +### 7.4 Canonical model family 的作用 + +`normalized_model_id` 只能解决字符串归一化,不能稳定回答跨中转的“是否同一模型家族”。 + +V2 需要额外记录 `canonical_model_family`,用于识别这类情况: + +- `kimi 2.6` +- `kimi-2.6` +- `kimi-k2.6` + +它们可能属于同一个模型家族,应支持: + +1. 重复导入时快速匹配 +2. 已存在 provider 只 patch 别名映射,不重复 provision +3. 结果页解释“为何这次被直接复用” + ## 8. State store schema ### 8.1 `import_runs` @@ -278,15 +313,22 @@ item_id TEXT PRIMARY KEY run_id TEXT NOT NULL base_url TEXT NOT NULL provider_id TEXT NOT NULL +api_key_fingerprint TEXT NOT NULL requested_models_json TEXT NOT NULL raw_models_json TEXT NOT NULL normalized_models_json TEXT NOT NULL +canonical_model_families_json TEXT NOT NULL resolved_smoke_model TEXT NULL recommended_models_json TEXT NOT NULL capability_profile_json TEXT NOT NULL current_stage TEXT NOT NULL confirmation_status TEXT NOT NULL access_status TEXT NOT NULL +matched_account_state TEXT NOT NULL +account_resolution TEXT NOT NULL +provision_reused INTEGER NOT NULL +reused_from_provider_id TEXT NULL +reused_from_account_id INTEGER NULL channel_id INTEGER NULL account_id INTEGER NULL retry_count INTEGER NOT NULL @@ -309,8 +351,54 @@ updated_at DATETIME NOT NULL - `resolved_smoke_model` 可为 `NULL`,因为 Stage 1 可能失败 - `channel_id/account_id` 可为 `NULL`,因为 Stage 2 可能未开始 - `access_status` 初始必须允许 `unknown` +- `api_key_fingerprint` 只存指纹,不存明文 key +- `canonical_model_families_json` 是 Reuse Preflight 的核心输入,而不是附带展示字段 +- `matched_account_state` 用于结果页直接展示“重复已启用 / 已弃用待启用 / 已重新启用” +- `account_resolution` 用于解释这次 item 最终是创建、复用、快速启用还是替换 -### 8.3 `import_run_item_events` +### 8.3 为什么必须有 key fingerprint + +只靠 `provider_id` 不能稳定区分: + +- 同一个 URL 下是否是同一把 key +- 是真正的重复导入,还是同 URL 的新账号 + +因此 V2 必须显式落: + +- `api_key_fingerprint` +- `provision_reused` +- `reused_from_provider_id` +- `reused_from_account_id` + +这样 Reuse Preflight 才能先按: + +1. `host_id + provider_id` +2. `host_id + base_url + api_key_fingerprint` +3. `canonical_model_families` + +做稳定判定。 + +### 8.4 已存在账号状态的标准化 + +对命中的既有账号,V2 需要统一投影为: + +- `active` + - 当前账号已启用,可直接使用 +- `disabled` + - 当前账号被停用,但可尝试快速启用 +- `deprecated` + - 当前账号不推荐继续调度,但仍可由 operator 重新启用 +- `broken` + - 当前账号不可直接复用 + +对应本次导入的处理结果: + +- `created` +- `reused` +- `reactivated` +- `replaced` + +### 8.5 `import_run_item_events` ```text event_id TEXT PRIMARY KEY @@ -331,7 +419,7 @@ created_at DATETIME NOT NULL - `advisory_added` - `validation_result` -### 8.4 为什么必须有 event 表 +### 8.6 为什么必须有 event 表 仅靠 `retry_count` 不足以支撑结果页要求。 页面要能展示: @@ -421,6 +509,14 @@ ValidationService 只做一件事: - `账号创建后宿主异步探测尚未稳定,首次 /test 已按 advisory 处理` - `gateway_warmup_retry_succeeded` - `初次调度出现 no available accounts,短暂重试后已恢复` +- `provision_reused` + - `已检测到同 URL + 同模型家族 + 健康账号,系统直接复用已有 provider` +- `patch_only_new_aliases` + - `模型属于已覆盖家族,仅补充别名映射与定价,不重复创建资源` +- `duplicate_active_account` + - `该账号已存在且处于启用状态,本次未重复创建,直接复用` +- `deprecated_account_reactivated` + - `该账号此前处于弃用/停用状态,本次已快速启用并重新确认` ## 12. Result pages @@ -457,11 +553,16 @@ Item 行至少包含: - `item_id` - `base_url` - `provider_id` +- `api_key_fingerprint` - `requested_models` +- `canonical_model_families` - `resolved_smoke_model` - `current_stage` - `confirmation_status` - `access_status` +- `matched_account_state` +- `account_resolution` +- `provision_reused` - `retry_count` - `last_error_stage` - `last_error` @@ -470,9 +571,14 @@ Item 详情至少包含: - `raw_models` - `normalized_models` +- `canonical_model_families` - `recommended_models` - `transport_profile` - `model_profiles` +- `matched_account_state` +- `account_resolution` +- `reused_from_provider_id` +- `reused_from_account_id` - `channel_id` - `account_id` - `advisory_messages` diff --git a/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_MIGRATION_DRAFT.md b/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_MIGRATION_DRAFT.md index 6eb532b7..b10a38a3 100644 --- a/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_MIGRATION_DRAFT.md +++ b/docs/2026-05-22-BATCH_AUTO_IMPORT_V2_MIGRATION_DRAFT.md @@ -34,7 +34,12 @@ V2 的目标不是替换 v1 的执行链,而是新增一套**面向长任务 - item 可以记录 `legacy_provider_id` - 仅用于追溯,不用于投影 -5. **按 SQLite 友好方式设计** +5. **支持重复导入复用** + - 明确记录 `api_key_fingerprint` + - 明确记录 `provision_reused` 与复用来源 + - 支撑“同 URL + 同模型家族”直接复用 + +6. **按 SQLite 友好方式设计** - 不使用复杂 JSON 索引 - 关键筛选字段保留标量列 - 复杂结构用 `TEXT` JSON 保存 @@ -94,9 +99,11 @@ CREATE TABLE import_run_items ( run_id TEXT NOT NULL, base_url TEXT NOT NULL, provider_id TEXT NOT NULL, + api_key_fingerprint TEXT NOT NULL, requested_models_json TEXT NOT NULL DEFAULT '[]', raw_models_json TEXT NOT NULL DEFAULT '[]', normalized_models_json TEXT NOT NULL DEFAULT '[]', + canonical_model_families_json TEXT NOT NULL DEFAULT '[]', recommended_models_json TEXT NOT NULL DEFAULT '[]', resolved_smoke_model TEXT NULL, capability_profile_json TEXT NOT NULL DEFAULT '{}', @@ -104,6 +111,11 @@ CREATE TABLE import_run_items ( current_stage TEXT NOT NULL, confirmation_status TEXT NOT NULL, access_status TEXT NOT NULL, + matched_account_state TEXT NOT NULL DEFAULT 'none', + account_resolution TEXT NOT NULL DEFAULT 'created', + provision_reused INTEGER NOT NULL DEFAULT 0, + reused_from_provider_id TEXT NULL, + reused_from_account_id INTEGER NULL, channel_id INTEGER NULL, account_id INTEGER NULL, @@ -139,6 +151,7 @@ CREATE TABLE import_run_items ( ```sql CREATE INDEX idx_import_run_items_run_id ON import_run_items(run_id); CREATE INDEX idx_import_run_items_provider_id ON import_run_items(provider_id); +CREATE INDEX idx_import_run_items_key_fingerprint ON import_run_items(api_key_fingerprint); CREATE INDEX idx_import_run_items_current_stage ON import_run_items(current_stage); CREATE INDEX idx_import_run_items_confirmation_status ON import_run_items(confirmation_status); CREATE INDEX idx_import_run_items_access_status ON import_run_items(access_status); @@ -151,9 +164,12 @@ CREATE INDEX idx_import_run_items_lease_until ON import_run_items(lease_until); 下列字段必须保留标量列,不能只藏在 JSON 里: - `provider_id` +- `api_key_fingerprint` - `current_stage` - `confirmation_status` - `access_status` +- `matched_account_state` +- `account_resolution` - `next_retry_at` - `lease_until` @@ -163,6 +179,45 @@ CREATE INDEX idx_import_run_items_lease_until ON import_run_items(lease_until); - 结果页列表要按这些字段筛选 - SQLite 下从 JSON 中筛选成本高、代码复杂度高 +## 4.4 重复导入复用预检查 + +V2 需要显式支持: + +- 已成功导入的 provider 再次添加时自动复用 +- 同模型不同别名只 patch mapping + +因此 migration 必须支撑以下预检查顺序: + +1. `host_id + provider_id` +2. `host_id + base_url + api_key_fingerprint` +3. `canonical_model_families_json` 与现有 provider 覆盖关系比较 + +结果分三类: + +- `reused` + - `provision_reused=1` + - 写入 `reused_from_provider_id` + - 视情况写入 `reused_from_account_id` +- `patch_only` + - 不重建 provider/account + - 仅更新 alias/model_mapping/model_pricing +- `replace` + - 原 provider broken 或 key 失效 + - 重新 provision + +同时对命中的既有账号,还要额外落两类语义: + +- `matched_account_state` + - `active | disabled | deprecated | broken` +- `account_resolution` + - `created | reused | reactivated | replaced` + +这样结果页才能直接显示: + +- “重复,已启用” +- “已弃用,已快速启用” +- “已损坏,已替换” + ## 5. `0008_batch_import_run_events.sql` ### 5.1 `import_run_item_events` @@ -325,6 +380,7 @@ LIMIT ?; 1. **幂等** - `run_id`、`item_id` 由控制面生成,不能依赖数据库自增主键做外部 API 标识 + - `api_key_fingerprint + provider_id + canonical_model_families` 是重复导入复用的关键判定输入 2. **可恢复** - 任何阶段切换前后都要先写 item diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 735e85ba..7f32ac85 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -169,6 +169,9 @@ - capability 从 upstream 总画像升级为 transport + model profiles - 结果页字段、状态库存储字段、retry/event trail 已统一 - OpenAPI 已补齐 `/api/batch-import/runs*`,legacy `/api/import-batches/*` 降级为 v1/legacy + - 已补充重复导入自动复用策略:按 `provider_id + api_key_fingerprint + canonical_model_family` 判断 `reused / patch_only / replace` + - 已补充同模型别名归一化契约:例如 `kimi 2.6 / kimi-2.6 / kimi-k2.6` 可归并到同一模型家族并快速复用 + - 已补充多账号重复导入与弃用账号再启用策略:active 账号提示“重复已启用”,disabled/deprecated 账号显示原状态并走 `reactivated` 快速启用路径 **当前剩余项**: - [x] 按收口后的 canonical contract 输出数据库 migration 草案 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 27944dbb..062b7a8f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -736,6 +736,8 @@ components: type: string normalized_model_id: type: string + canonical_model_family: + type: string supports_stream: type: string supports_tools: @@ -762,10 +764,16 @@ components: type: string provider_id: type: string + api_key_fingerprint: + type: string requested_models: type: array items: type: string + canonical_model_families: + type: array + items: + type: string resolved_smoke_model: type: string nullable: true @@ -778,6 +786,14 @@ components: access_status: type: string enum: [unknown, active, degraded, broken] + matched_account_state: + type: string + enum: [none, active, disabled, deprecated, broken] + account_resolution: + type: string + enum: [created, reused, reactivated, replaced] + provision_reused: + type: boolean retry_count: type: integer last_retry_at: @@ -825,10 +841,21 @@ components: type: array items: type: string + canonical_model_families: + type: array + items: + type: string recommended_models: type: array items: type: string + reused_from_provider_id: + type: string + nullable: true + reused_from_account_id: + type: integer + format: int64 + nullable: true channel_id: type: integer format: int64