2026-05-22 06:51:44 +08:00
|
|
|
|
# TDD 实施计划 v2 — Batch Auto-Import
|
|
|
|
|
|
|
|
|
|
|
|
日期:2026-05-21
|
2026-05-22 13:18:51 +08:00
|
|
|
|
技术架构:`docs/2026-05-22-BATCH_AUTO_IMPORT_V2_ARCHITECTURE.md`
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 1. 目标
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
本计划只服务一件事:把 V2 设计落成**可测试、可恢复、可观察**的实现路径。
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
对应目标:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
1. URL + key 自动发现模型
|
|
|
|
|
|
2. 模型名归一化与推荐纠错
|
2026-05-22 14:15:41 +08:00
|
|
|
|
3. 跨中转同模型快速匹配与复用
|
|
|
|
|
|
4. provider/model 兼容画像建模
|
|
|
|
|
|
5. 宿主资源演化与 provider 绑定
|
|
|
|
|
|
6. 后台异步确认与有限重试
|
|
|
|
|
|
7. 最终 gateway completion 验证
|
|
|
|
|
|
8. run/item 状态持久化与结果页可读
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 2. Canonical Contract
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
实现前先锁定 canonical contract,测试、接口、状态表全部按这一套。
|
|
|
|
|
|
|
|
|
|
|
|
### 2.1 核心 ID
|
|
|
|
|
|
|
|
|
|
|
|
- `run_id string`
|
|
|
|
|
|
- `item_id string`
|
|
|
|
|
|
- `provider_id string = {normalized_host}-{url_hash_last8}`
|
|
|
|
|
|
|
|
|
|
|
|
### 2.2 Run 状态
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type RunState string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
RunStateRunning RunState = "running"
|
|
|
|
|
|
RunStateCompleted RunState = "completed"
|
|
|
|
|
|
RunStateCompletedWithWarnings RunState = "completed_with_warnings"
|
|
|
|
|
|
RunStateFailed RunState = "failed"
|
|
|
|
|
|
RunStateCancelled RunState = "cancelled"
|
|
|
|
|
|
)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.3 Item 状态
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type ItemStage string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
ItemStageProbe ItemStage = "probe"
|
|
|
|
|
|
ItemStageProvision ItemStage = "provision"
|
|
|
|
|
|
ItemStageConfirm ItemStage = "confirm"
|
|
|
|
|
|
ItemStageValidate ItemStage = "validate"
|
|
|
|
|
|
ItemStageDone ItemStage = "done"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type ConfirmationStatus string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
ConfirmationPending ConfirmationStatus = "pending"
|
|
|
|
|
|
ConfirmationConfirmed ConfirmationStatus = "confirmed"
|
|
|
|
|
|
ConfirmationAdvisory ConfirmationStatus = "advisory"
|
|
|
|
|
|
ConfirmationFailed ConfirmationStatus = "failed"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type AccessStatus string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
AccessStatusUnknown AccessStatus = "unknown"
|
|
|
|
|
|
AccessStatusActive AccessStatus = "active"
|
|
|
|
|
|
AccessStatusDegraded AccessStatus = "degraded"
|
|
|
|
|
|
AccessStatusBroken AccessStatus = "broken"
|
|
|
|
|
|
)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### 2.4 Access Mode 输入
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type BatchImportRunRequest struct {
|
|
|
|
|
|
HostID string
|
|
|
|
|
|
Mode string
|
|
|
|
|
|
AccessMode string
|
|
|
|
|
|
ConfirmWaitTimeoutSec int
|
|
|
|
|
|
SubscriptionUsers []string
|
|
|
|
|
|
SubscriptionDays int
|
|
|
|
|
|
ProbeAPIKey string
|
|
|
|
|
|
Entries []BatchImportEntry
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type BatchImportEntry struct {
|
|
|
|
|
|
BaseURL string
|
|
|
|
|
|
APIKey string
|
|
|
|
|
|
RequestedModels []string
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
校验规则:
|
|
|
|
|
|
|
|
|
|
|
|
- `subscription` 必须有 `SubscriptionUsers` + `SubscriptionDays`
|
|
|
|
|
|
- `self_service` 必须有 `ProbeAPIKey`
|
|
|
|
|
|
- `RequestedModels` 只作提示,不作事实源
|
|
|
|
|
|
|
|
|
|
|
|
## 3. 实现顺序
|
|
|
|
|
|
|
|
|
|
|
|
必须按以下顺序做:
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```text
|
|
|
|
|
|
probe/models + probe/aliases
|
|
|
|
|
|
↓
|
|
|
|
|
|
probe/capability + probe/completion
|
|
|
|
|
|
↓
|
|
|
|
|
|
batch/provider_id + batch/capability_profile
|
|
|
|
|
|
↓
|
2026-05-22 13:38:56 +08:00
|
|
|
|
host/channel_patch_contract
|
|
|
|
|
|
↓
|
|
|
|
|
|
batch/run_state + batch/run_events
|
2026-05-22 13:18:51 +08:00
|
|
|
|
↓
|
|
|
|
|
|
batch/service
|
|
|
|
|
|
↓
|
2026-05-22 13:38:56 +08:00
|
|
|
|
batch/confirmation_worker
|
|
|
|
|
|
↓
|
|
|
|
|
|
batch/validation
|
2026-05-22 13:18:51 +08:00
|
|
|
|
↓
|
|
|
|
|
|
app/http_batch_import + app/http_batch_runs
|
|
|
|
|
|
↓
|
|
|
|
|
|
cmd/cli/batch_import
|
|
|
|
|
|
↓
|
|
|
|
|
|
tests/integration/batch_import
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
原则:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 先锁死状态契约,再写 worker
|
|
|
|
|
|
- 先让状态库存得全,再做结果页
|
|
|
|
|
|
- 先让 Validation Engine 成为 `access_status` 唯一写入方,再做 projection
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 4. Stage 1: Probe
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 4.1 `internal/probe/models.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:拉取 `/v1/models`。
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:18:51 +08:00
|
|
|
|
type ModelsResult struct {
|
2026-05-22 13:38:56 +08:00
|
|
|
|
RawModels []string
|
|
|
|
|
|
HTTPStatus int
|
|
|
|
|
|
LatencyMs int64
|
|
|
|
|
|
Error string
|
2026-05-22 13:18:51 +08:00
|
|
|
|
}
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func ProviderModels(ctx context.Context, baseURL, apiKey string) (*ModelsResult, error)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
|
|
|
|
|
func TestProviderModels_OpenAIFormat_ReturnsModelList(t *testing.T)
|
|
|
|
|
|
func TestProviderModels_EmptyData_ReturnsEmptySlice(t *testing.T)
|
|
|
|
|
|
func TestProviderModels_AuthFailed_ReturnsErrAuthFailed(t *testing.T)
|
|
|
|
|
|
func TestProviderModels_Timeout_ReturnsErrUpstreamUnreachable(t *testing.T)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 4.2 `internal/probe/aliases.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:模型归一化、别名、推荐纠错。
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:18:51 +08:00
|
|
|
|
type AliasResult struct {
|
|
|
|
|
|
Raw string
|
|
|
|
|
|
Normalized string
|
|
|
|
|
|
Canonical string
|
2026-05-22 06:51:44 +08:00
|
|
|
|
}
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
func NormalizeModelID(raw string) string
|
|
|
|
|
|
func CanonicalModelID(raw string) string
|
2026-05-22 14:15:41 +08:00
|
|
|
|
func CanonicalModelFamily(raw string) string
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func BuildAliasTable(rawModels []string) map[string]AliasResult
|
|
|
|
|
|
func ResolveRequestedModel(requested string, rawModels []string) (resolved string, ok bool)
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func RecommendModels(requested []string, rawModels []string) []string
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func TestNormalizeModelID_MinimaxCanonical(t *testing.T)
|
|
|
|
|
|
func TestNormalizeModelID_DeepSeekVendorPrefix(t *testing.T)
|
2026-05-22 14:15:41 +08:00
|
|
|
|
func TestCanonicalModelFamily_KimiVariantsCollapseToSameFamily(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func TestResolveRequestedModel_UsesNormalizedAlias(t *testing.T)
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestRecommendModels_ReturnsCanonicalCandidates(t *testing.T)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 4.3 `internal/probe/capability.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:生成 transport profile + model profiles。
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type TransportProfile struct {
|
2026-05-22 13:18:51 +08:00
|
|
|
|
SupportsOpenAIModels bool
|
|
|
|
|
|
SupportsOpenAIChatCompletions bool
|
|
|
|
|
|
SupportsOpenAIResponses bool
|
|
|
|
|
|
SupportsAnthropicMessages bool
|
|
|
|
|
|
AuthStyle string
|
|
|
|
|
|
ModelIDStyle string
|
|
|
|
|
|
KnownAdvisories []string
|
2026-05-22 06:51:44 +08:00
|
|
|
|
}
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type ModelCapabilityProfile struct {
|
|
|
|
|
|
RawModelID string
|
|
|
|
|
|
NormalizedModelID string
|
|
|
|
|
|
SupportsStream string
|
|
|
|
|
|
SupportsTools string
|
|
|
|
|
|
SupportsReasoningFields string
|
|
|
|
|
|
SmokeChatOK bool
|
|
|
|
|
|
}
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type CapabilityProfile struct {
|
|
|
|
|
|
Transport TransportProfile
|
|
|
|
|
|
ModelProfile []ModelCapabilityProfile
|
|
|
|
|
|
}
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func ProbeCapabilities(ctx context.Context, baseURL, apiKey string, rawModels []string) (*CapabilityProfile, error)
|
|
|
|
|
|
```
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func TestProbeCapabilities_Responses403Chat200_MarksResponsesUnsupported(t *testing.T)
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestProbeCapabilities_ModelProfilesCapturedPerModel(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func TestProbeCapabilities_RecordsKnownAdvisories(t *testing.T)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 4.4 `internal/probe/completion.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:决定 smoke model,并做最小 completion。
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
2026-05-22 13:18:51 +08:00
|
|
|
|
type CompletionResult struct {
|
|
|
|
|
|
Model string
|
|
|
|
|
|
HTTPStatus int
|
|
|
|
|
|
LatencyMs int64
|
|
|
|
|
|
Classification string
|
|
|
|
|
|
Error string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func ResolveSmokeModel(requested []string, rawModels []string, profile *CapabilityProfile) (string, []string, error)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func SmokeCompletion(ctx context.Context, baseURL, apiKey, model string, profile *CapabilityProfile) (*CompletionResult, error)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func TestResolveSmokeModel_UsesRequestedAliasWhenMatched(t *testing.T)
|
|
|
|
|
|
func TestResolveSmokeModel_FallsBackToDiscoveredModel(t *testing.T)
|
|
|
|
|
|
func TestSmokeCompletion_ResponsesUnsupported_UsesChatCompletions(t *testing.T)
|
|
|
|
|
|
```
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 5. Stage 2: Provision & Channel Evolution
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 5.1 `internal/batch/provider_id.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func NormalizeProviderID(baseURL string) string
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
规则:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 规范化 host
|
|
|
|
|
|
- 基于完整 URL 做 hash
|
|
|
|
|
|
- 同 host 不同 path 必须不同 ID
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
|
|
|
|
|
func TestNormalizeProviderID_Basic(t *testing.T)
|
|
|
|
|
|
func TestNormalizeProviderID_WithPath_IncludesPathHash(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func TestNormalizeProviderID_DifferentPaths_DifferentIDs(t *testing.T)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 5.2 `internal/batch/capability_profile.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:把 capability profile 转成导入/确认策略。
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:18:51 +08:00
|
|
|
|
type ImportRoutingStrategy struct {
|
|
|
|
|
|
UseRawChatCompletions bool
|
|
|
|
|
|
SkipResponsesChecks bool
|
|
|
|
|
|
RetryInitial503 bool
|
2026-05-22 13:38:56 +08:00
|
|
|
|
TreatProbe403Advisory bool
|
2026-05-22 13:18:51 +08:00
|
|
|
|
}
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func BuildImportRoutingStrategy(profile *probe.CapabilityProfile) ImportRoutingStrategy
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func TestBuildImportRoutingStrategy_ResponsesUnsupported_UsesRawChat(t *testing.T)
|
|
|
|
|
|
func TestBuildImportRoutingStrategy_ProbeRaceAdvisory_EnablesProbe403Advisory(t *testing.T)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 5.3 `internal/batch/channel_evolution.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:构造完整 channel patch contract。
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type ChannelPatchContract struct {
|
|
|
|
|
|
ModelMapping map[string]string
|
|
|
|
|
|
ModelPricing map[string]any
|
|
|
|
|
|
RestrictModels bool
|
|
|
|
|
|
BillingModelSource string
|
|
|
|
|
|
}
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func ModelMappingDelta(existing map[string]string, discoveredAliases map[string]probe.AliasResult) ChannelPatchContract
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestModelMappingDelta_PreservesExistingEntries(t *testing.T)
|
|
|
|
|
|
func TestModelMappingDelta_AddsRawToCanonicalMappings(t *testing.T)
|
|
|
|
|
|
func TestModelMappingDelta_SetsRestrictModelsAndBillingSource(t *testing.T)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 14:15:41 +08:00
|
|
|
|
### 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)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 6. Stage 3: State Store
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 6.1 `internal/batch/run_state.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
V2 canonical runtime store:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- `import_runs`
|
|
|
|
|
|
- `import_run_items`
|
|
|
|
|
|
- `import_run_item_events`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type ImportRunState struct {
|
2026-05-22 13:38:56 +08:00
|
|
|
|
RunID string
|
|
|
|
|
|
Mode string
|
|
|
|
|
|
AccessMode string
|
|
|
|
|
|
State RunState
|
|
|
|
|
|
TotalItems int
|
|
|
|
|
|
CompletedItems int
|
|
|
|
|
|
ActiveItems int
|
|
|
|
|
|
DegradedItems int
|
|
|
|
|
|
BrokenItems int
|
|
|
|
|
|
WarningItems int
|
|
|
|
|
|
StartedAt time.Time
|
|
|
|
|
|
UpdatedAt time.Time
|
|
|
|
|
|
FinishedAt *time.Time
|
2026-05-22 13:18:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ImportRunItemState struct {
|
2026-05-22 13:38:56 +08:00
|
|
|
|
RunID string
|
|
|
|
|
|
ItemID string
|
|
|
|
|
|
BaseURL string
|
|
|
|
|
|
ProviderID string
|
2026-05-22 14:15:41 +08:00
|
|
|
|
APIKeyFingerprint string
|
2026-05-22 13:38:56 +08:00
|
|
|
|
CurrentStage ItemStage
|
|
|
|
|
|
ConfirmationStatus ConfirmationStatus
|
|
|
|
|
|
AccessStatus AccessStatus
|
2026-05-22 14:15:41 +08:00
|
|
|
|
MatchedAccountState string
|
|
|
|
|
|
AccountResolution string
|
2026-05-22 13:38:56 +08:00
|
|
|
|
RequestedModels []string
|
|
|
|
|
|
RawModels []string
|
|
|
|
|
|
NormalizedModels []string
|
2026-05-22 14:15:41 +08:00
|
|
|
|
CanonicalModelFamilies []string
|
2026-05-22 13:38:56 +08:00
|
|
|
|
ResolvedSmokeModel *string
|
|
|
|
|
|
RecommendedModels []string
|
|
|
|
|
|
CapabilityProfileJSON string
|
|
|
|
|
|
ChannelID *int64
|
|
|
|
|
|
AccountID *int64
|
|
|
|
|
|
RetryCount int
|
|
|
|
|
|
ConfirmationAttempts int
|
|
|
|
|
|
LastRetryAt *time.Time
|
|
|
|
|
|
NextRetryAt *time.Time
|
|
|
|
|
|
LeaseOwner *string
|
|
|
|
|
|
LeaseUntil *time.Time
|
|
|
|
|
|
AdvisoryMessages []string
|
|
|
|
|
|
LastErrorStage *string
|
|
|
|
|
|
LastError *string
|
|
|
|
|
|
LegacyBatchID *int64
|
|
|
|
|
|
LegacyProviderID *string
|
2026-05-22 14:15:41 +08:00
|
|
|
|
ProvisionReused bool
|
|
|
|
|
|
ReusedFromProviderID *string
|
|
|
|
|
|
ReusedFromAccountID *int64
|
2026-05-22 13:38:56 +08:00
|
|
|
|
CreatedAt time.Time
|
|
|
|
|
|
UpdatedAt time.Time
|
2026-05-22 13:18:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type ImportRunItemEvent struct {
|
|
|
|
|
|
EventID string
|
|
|
|
|
|
RunID string
|
|
|
|
|
|
ItemID string
|
|
|
|
|
|
EventType string
|
|
|
|
|
|
Stage string
|
|
|
|
|
|
Attempt int
|
|
|
|
|
|
Message string
|
|
|
|
|
|
PayloadJSON string
|
|
|
|
|
|
CreatedAt time.Time
|
2026-05-22 13:18:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
func TestRunStateStore_CreateAndUpdateRun(t *testing.T)
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestRunStateStore_UpsertItemStoresProjectionFields(t *testing.T)
|
|
|
|
|
|
func TestRunStateStore_EventTrailCanBeQueried(t *testing.T)
|
|
|
|
|
|
func TestRunStateStore_LeaseFieldsPersist(t *testing.T)
|
2026-05-22 14:15:41 +08:00
|
|
|
|
func TestRunStateStore_AccountReuseFieldsPersist(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 7. Stage 4: Batch Service
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 7.1 `internal/batch/service.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type BatchImportService struct {
|
|
|
|
|
|
Host hostadapter.HostAdapter
|
|
|
|
|
|
Probe *probe.Client
|
|
|
|
|
|
Provision *provision.ImportService
|
|
|
|
|
|
StateStore RunStateStore
|
|
|
|
|
|
Queue ConfirmationQueue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *BatchImportService) StartRun(ctx context.Context, req BatchImportRunRequest) (*BatchImportRunResult, error)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
职责:
|
|
|
|
|
|
|
|
|
|
|
|
- 创建 run + item
|
2026-05-22 14:15:41 +08:00
|
|
|
|
- 先执行 reuse preflight,决定是复用、patch 还是 replace
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 先落 probe/provision 结果
|
|
|
|
|
|
- 入队 confirm,不在请求线程里承担全部确认责任
|
|
|
|
|
|
- CLI/HTTP 只负责“发起”和“可选等待窗口”
|
|
|
|
|
|
|
|
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestBatchImport_StartRun_PersistsInitialState(t *testing.T)
|
|
|
|
|
|
func TestBatchImport_RequestedModelMiss_UsesDiscoveredModel(t *testing.T)
|
|
|
|
|
|
func TestBatchImport_ProvisionWritesLegacyLinks(t *testing.T)
|
2026-05-22 14:15:41 +08:00
|
|
|
|
func TestBatchImport_ExistingActiveProviderAndCoveredFamilies_ReusesProvision(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 8. Stage 5: Confirmation Worker
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 8.1 `internal/batch/confirmation.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
type ConfirmationWorker struct {
|
|
|
|
|
|
Host hostadapter.HostAdapter
|
|
|
|
|
|
StateStore RunStateStore
|
|
|
|
|
|
Validate ValidationService
|
|
|
|
|
|
Clock Clock
|
|
|
|
|
|
WorkerID string
|
2026-05-22 13:18:51 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func (w *ConfirmationWorker) Tick(ctx context.Context, now time.Time) error
|
|
|
|
|
|
func (w *ConfirmationWorker) ConfirmItem(ctx context.Context, item ImportRunItemState) (*ImportRunItemState, error)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
行为:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 轮询 `current_stage=confirm` 且 `next_retry_at<=now` 的 item
|
|
|
|
|
|
- 获取 lease
|
|
|
|
|
|
- 执行 account models / account test / transient 503 absorb
|
|
|
|
|
|
- 写 `confirmation_status = confirmed | advisory | failed`
|
|
|
|
|
|
- confirm 完毕后推进到 `validate`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
约束:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 首次 `403` probe race:若 `/models` 已正确且 profile 说明 `responses` 不支持,则标记 `advisory`
|
|
|
|
|
|
- `confirmation_status` 不是最终可用性
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestConfirmationWorker_Probe403Race_ReturnsAdvisory(t *testing.T)
|
|
|
|
|
|
func TestConfirmationWorker_UsesLeaseAndNextRetryAt(t *testing.T)
|
|
|
|
|
|
func TestConfirmationWorker_RestartCanResumeUnlockedItem(t *testing.T)
|
|
|
|
|
|
```
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 9. Stage 6: Validation Engine
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 9.1 `internal/batch/validation.go`
|
|
|
|
|
|
|
|
|
|
|
|
```go
|
|
|
|
|
|
type ValidationService struct {
|
2026-05-22 13:18:51 +08:00
|
|
|
|
Host hostadapter.HostAdapter
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func (s *ValidationService) ValidateAccess(ctx context.Context, item ImportRunItemState, req BatchImportRunRequest) (AccessStatus, []string, error)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
规则:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 只有这里能最终写 `access_status`
|
|
|
|
|
|
- `confirmed + gateway chat 200` → `active`
|
|
|
|
|
|
- `advisory + gateway chat 200` → `active`
|
|
|
|
|
|
- `gateway chat transient but exhausted` → `degraded`
|
|
|
|
|
|
- `gateway chat definitively failed` → `broken`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestValidationService_GatewayChat200_ReturnsActive(t *testing.T)
|
|
|
|
|
|
func TestValidationService_Transient503Exhausted_ReturnsDegraded(t *testing.T)
|
|
|
|
|
|
func TestValidationService_FinalFailure_ReturnsBroken(t *testing.T)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 10. Stage 7: HTTP API & Result Pages
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 10.1 `internal/app/http_batch_import.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func (a *App) createBatchImportRun(w http.ResponseWriter, r *http.Request)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func (a *App) listBatchImportRuns(w http.ResponseWriter, r *http.Request)
|
|
|
|
|
|
func (a *App) getBatchImportRun(w http.ResponseWriter, r *http.Request)
|
|
|
|
|
|
func (a *App) listBatchImportRunItems(w http.ResponseWriter, r *http.Request)
|
|
|
|
|
|
func (a *App) getBatchImportRunItem(w http.ResponseWriter, r *http.Request)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
要求:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- 直接返回 projection,不让页面自己拼状态
|
|
|
|
|
|
- 列表页筛选使用 `run.state`
|
|
|
|
|
|
- item 详情必须返回 event trail
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestCreateBatchImportRun_ValidatesAccessModeInputs(t *testing.T)
|
|
|
|
|
|
func TestListBatchImportRuns_ReturnsCanonicalState(t *testing.T)
|
|
|
|
|
|
func TestGetBatchImportRunItem_ReturnsEventTrailAndRecommendedModels(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 10.2 `internal/app/http_batch_runs.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
页面:
|
|
|
|
|
|
|
|
|
|
|
|
- `/batch-import/runs`
|
|
|
|
|
|
- `/batch-import/runs/{run_id}`
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
单测:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestBatchImportRunsPage_RendersCanonicalBadges(t *testing.T)
|
|
|
|
|
|
func TestBatchImportRunDetailPage_RendersCapabilitySummary(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 11. Stage 8: CLI
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
### 11.1 `cmd/cli/batch_import.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
go run ./cmd/cli batch-import \
|
2026-05-22 13:38:56 +08:00
|
|
|
|
--host-id string \
|
2026-05-22 13:18:51 +08:00
|
|
|
|
--entry "url,key" \
|
|
|
|
|
|
--batch-file string \
|
|
|
|
|
|
--mode "strict|partial" \
|
|
|
|
|
|
--access-mode "subscription|self_service" \
|
2026-05-22 13:38:56 +08:00
|
|
|
|
--subscription-users "u1,u2" \
|
|
|
|
|
|
--subscription-days 30 \
|
|
|
|
|
|
--probe-api-key string \
|
|
|
|
|
|
--confirm-wait-timeout 15s
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
CLI 集成测试:
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```go
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestBatchImportCLI_ReportsRunIDAndResultPage(t *testing.T)
|
|
|
|
|
|
func TestBatchImportCLI_ReportsResolvedAndRecommendedModels(t *testing.T)
|
|
|
|
|
|
func TestBatchImportCLI_ReportsConfirmationAndAccessStatus(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 12. Integration Tests
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
### `tests/integration/batch_import_test.go`
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
覆盖场景:
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
|
|
|
|
|
1. 标准 OpenAI-compatible 上游成功导入
|
2026-05-22 13:38:56 +08:00
|
|
|
|
2. 人工模型名错误,alias 自动纠正
|
|
|
|
|
|
3. `/responses=403` 但 `/chat/completions=200`
|
|
|
|
|
|
4. 首次 `/accounts/:id/test=403`,稍后转 advisory
|
|
|
|
|
|
5. 首次 `/v1/chat/completions=503 no available accounts`,重试后 200
|
|
|
|
|
|
6. capability profile 按模型粒度输出
|
|
|
|
|
|
7. 导入进行中可查询 run/item 状态
|
|
|
|
|
|
8. 控制面重启后 worker 能继续拾取 unfinished item
|
2026-05-22 13:18:51 +08:00
|
|
|
|
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```go
|
|
|
|
|
|
func TestBatchImport_FullPipeline(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func TestBatchImport_RequestedModelTypo_IsAutoCorrected(t *testing.T)
|
|
|
|
|
|
func TestBatchImport_ThirdPartyResponsesUnsupported_StillSucceeds(t *testing.T)
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestBatchImport_ProbeRace_BecomesAdvisory(t *testing.T)
|
2026-05-22 13:18:51 +08:00
|
|
|
|
func TestBatchImport_Initial503Warmup_RetrySucceeds(t *testing.T)
|
|
|
|
|
|
func TestBatchImport_RunStatusIsQueryableDuringExecution(t *testing.T)
|
2026-05-22 13:38:56 +08:00
|
|
|
|
func TestBatchImport_RunResultSurvivesRestartAndResumes(t *testing.T)
|
2026-05-22 06:51:44 +08:00
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 13. Required OpenAPI sync
|
|
|
|
|
|
|
|
|
|
|
|
实现时必须同步:
|
|
|
|
|
|
|
|
|
|
|
|
- `POST /api/batch-import/runs`
|
|
|
|
|
|
- `GET /api/batch-import/runs`
|
|
|
|
|
|
- `GET /api/batch-import/runs/{run_id}`
|
|
|
|
|
|
- `GET /api/batch-import/runs/{run_id}/items`
|
|
|
|
|
|
- `GET /api/batch-import/runs/{run_id}/items/{item_id}`
|
|
|
|
|
|
|
|
|
|
|
|
并将 `/api/import-batches/*` 标注为 v1/legacy。
|
|
|
|
|
|
|
|
|
|
|
|
## 14. Acceptance commands
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-05-22 13:18:51 +08:00
|
|
|
|
go test ./internal/probe/... -v -count=1
|
|
|
|
|
|
go test ./internal/batch/... -v -count=1
|
|
|
|
|
|
go test ./internal/app/... -v -count=1
|
|
|
|
|
|
go test ./internal/host/sub2api/... -v -count=1
|
|
|
|
|
|
go test ./tests/integration/... -count=1
|
|
|
|
|
|
go test -cover ./internal/... -count=1
|
2026-05-22 06:51:44 +08:00
|
|
|
|
go vet ./...
|
|
|
|
|
|
gofmt -l .
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-05-22 13:38:56 +08:00
|
|
|
|
## 15. Task checklist
|
2026-05-22 06:51:44 +08:00
|
|
|
|
|
2026-05-22 13:18:51 +08:00
|
|
|
|
- [ ] `internal/probe/models.go`
|
|
|
|
|
|
- [ ] `internal/probe/aliases.go`
|
|
|
|
|
|
- [ ] `internal/probe/capability.go`
|
|
|
|
|
|
- [ ] `internal/probe/completion.go`
|
|
|
|
|
|
- [ ] `internal/batch/provider_id.go`
|
|
|
|
|
|
- [ ] `internal/batch/capability_profile.go`
|
|
|
|
|
|
- [ ] `internal/batch/channel_evolution.go`
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- [ ] `internal/batch/run_state.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
- [ ] `internal/batch/service.go`
|
|
|
|
|
|
- [ ] `internal/batch/confirmation.go`
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- [ ] `internal/batch/validation.go`
|
2026-05-22 13:18:51 +08:00
|
|
|
|
- [ ] `internal/host/sub2api/channel.go`
|
|
|
|
|
|
- [ ] `internal/host/sub2api/accounts.go`
|
|
|
|
|
|
- [ ] `internal/app/http_batch_import.go`
|
|
|
|
|
|
- [ ] `internal/app/http_batch_runs.go`
|
2026-05-22 06:51:44 +08:00
|
|
|
|
- [ ] `cmd/cli/batch_import.go`
|
|
|
|
|
|
- [ ] `tests/integration/batch_import_test.go`
|
2026-05-22 13:38:56 +08:00
|
|
|
|
- [ ] `docs/openapi.yaml`
|