fix(provision): stabilize kimi a7m import closure

Downgrade the first third-party account test 403 to an advisory warning when models are already present, and retry transient gateway completion 503 responses during access closure.

Add regression coverage for the probe race and completion retry paths, update the execution board, and store the final v0.1.129 Kimi A7M fresh-host acceptance artifact that now reaches succeeded/active/subscription_ready.
This commit is contained in:
phamnazage-jpg
2026-05-22 12:33:12 +08:00
parent 56c6b9e452
commit 9134afed9f
29 changed files with 425 additions and 10 deletions

View File

@@ -0,0 +1,9 @@
{
"source": "manual:a7m-kimi:freshhost",
"provider_id": "kimi-a7m",
"upstream_key_prefix": "sk-FKg61gc51",
"upstream_key_suffix": "w6rqf2",
"host_base": "http://127.0.0.1:18109",
"db_name": "sub2api_kimi_fresh_20260522_120829",
"redis_db": "9"
}

View File

@@ -0,0 +1,9 @@
{
"crm_base": "http://127.0.0.1:18100",
"host_base": "http://127.0.0.1:18109",
"provider_id": "kimi-a7m",
"subscription_user_id": "2",
"subscription_user_key_prefix": "sk-177942307",
"db_name": "sub2api_kimi_fresh_20260522_120829",
"redis_db": "9"
}

View File

@@ -0,0 +1 @@
{"host_id": "local-v0129-kimi-fresh-1779423075", "base_url": "http://127.0.0.1:18109", "host_version": "0.1.129", "auth_type": "bearer", "status": "unsupported", "capabilities": {"groups": true, "channels": true, "plans": true, "accounts": true, "account_test": false, "account_models": true, "subscriptions": true}}

View File

@@ -0,0 +1,5 @@
HTTP/1.1 200 OK
Content-Type: application/json
Date: Fri, 22 May 2026 04:27:09 GMT
Content-Length: 1001

View File

@@ -0,0 +1 @@
{"accepted_keys_count":1,"access_status":"subscription_ready","accounts_count":1,"batch_id":38,"batch_status":"succeeded","channel":{"id":"1","name":"Kimi A7M 默认渠道-subscription"},"gateway":{"ok":true,"status_code":200,"models":["kimi-k2.6"],"has_expected_model":true,"completion_ok":true,"completion_status":200,"completion_content_type":"application/json","completion_body_preview":"{\"id\":\"msg_e428d081-aeac-4f7d-be2a-7c6caa19d737\",\"model\":\"kimi-k2.6\",\"object\":\"chat.completion\",\"created\":1779424029,\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Pong! 🏓\\n\\nI'm\"},\"finish_reason\":\"length\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":8,\"total_tokens\":18,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":0,\"audio_tokens\":0,\"image_tokens\":0},\"completion"},"group":{"id":"2","name":"Kimi A7M 默认分组-subscription"},"plan":{"id":"1","name":"Kimi A7M 默认套餐-subscription"},"provider_status":"active"}

View File

@@ -0,0 +1 @@
{"access_closures":[{"ID":43,"BatchID":38,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"msg_e428d081-aeac-4f7d-be2a-7c6caa19d737\\\",\\\"model\\\":\\\"kimi-k2.6\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779424029,\\\"choices\\\":[{\\\"index\\\":0,\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong! 🏓\\\\n\\\\nI'm\\\"},\\\"finish_reason\\\":\\\"length\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":10,\\\"completion_tokens\\\":8,\\\"total_tokens\\\":18,\\\"prompt_tokens_details\\\":{\\\"cached_tokens\\\":0,\\\"text_tokens\\\":0,\\\"audio_tokens\\\":0,\\\"image_tokens\\\":0},\\\"completion\",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"kimi-k2.6\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":3,"id":38,"mode":"partial","pack_id":38,"provider_id":48},"items":[{"account_status":"warning","batch_id":38,"id":38,"key_fingerprint":"sha256:100830605ca92c766278d12cea58d6fc5d5eb27902a491d6c8e4fe13900a3bbe","probe_summary_json":"{\"account_id\":\"4\",\"models\":[{\"id\":\"kimi-k2.6\",\"display_name\":\"kimi-k2.6\",\"type\":\"model\"}],\"probe_advisory\":true,\"probe_message\":\"API returned 403: Forbidden\",\"probe_ok\":false,\"probe_status\":\"failed\",\"smoke_model_seen\":true,\"validation_status\":\"warning\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":65,"BatchID":38,"HostID":3,"ResourceType":"account","HostResourceID":"4","ResourceName":"kimi-a7m-01"}],"reconcile_count":0,"reconcile_runs":[]}

View File

@@ -0,0 +1,25 @@
BEGIN;
UPDATE users
SET balance = CASE WHEN balance < 10 THEN 10 ELSE balance END,
updated_at = now()
WHERE id = 2;
UPDATE api_keys
SET group_id = 2,
updated_at = now()
WHERE key = 'sk-1779423075-ae5f2b09';
INSERT INTO user_subscriptions (
user_id, group_id, starts_at, expires_at, status, assigned_by, assigned_at, notes, created_at, updated_at, deleted_at
) VALUES (
2, 2, now(), now() + interval '30 days', 'active', 1, now(), 'local v0.1.129 freshhost kimi validation', now(), now(), NULL
)
ON CONFLICT (user_id, group_id) WHERE deleted_at IS NULL
DO UPDATE SET
starts_at = EXCLUDED.starts_at,
expires_at = EXCLUDED.expires_at,
status = 'active',
assigned_by = EXCLUDED.assigned_by,
assigned_at = EXCLUDED.assigned_at,
notes = EXCLUDED.notes,
updated_at = now(),
deleted_at = NULL;
COMMIT;

View File

@@ -0,0 +1,5 @@
BEGIN
UPDATE 1
UPDATE 1
INSERT 0 1
COMMIT

View File

@@ -0,0 +1,4 @@
auth_cache_key=apikey:auth:fc26062e46b5878e43d427af92c522baefef87a59512163b32da3189005b523a
balance_cache_key=billing:balance:2
subscription_cache_key=billing:sub:2:2
0

View File

@@ -0,0 +1 @@
{"group_id" : 2, "group" : {"id":2,"name":"Kimi A7M 默认分组-subscription","description":"","rate_multiplier":1.0000,"is_exclusive":false,"status":"active","created_at":"2026-05-22T04:11:15.989375+00:00","updated_at":"2026-05-22T04:11:15.989375+00:00","deleted_at":null,"platform":"openai","subscription_type":"subscription","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null,"default_validity_days":0,"image_price_1k":null,"image_price_2k":null,"image_price_4k":null,"claude_code_only":false,"fallback_group_id":null,"model_routing":{},"model_routing_enabled":false,"fallback_group_id_on_invalid_request":null,"mcp_xml_inject":true,"supported_model_scopes":null,"sort_order":0,"allow_messages_dispatch":false,"default_mapped_model":"","require_oauth_only":false,"require_privacy_set":false,"messages_dispatch_model_config":{},"rpm_limit":0,"allow_image_generation":false,"image_rate_independent":false,"image_rate_multiplier":1.0000}, "subscription" : {"id":2,"user_id":2,"group_id":2,"starts_at":"2026-05-22T04:27:09.353018+00:00","expires_at":"2026-06-21T04:27:09.353018+00:00","status":"active","daily_window_start":"2026-05-21T16:00:00+00:00","weekly_window_start":"2026-05-21T16:00:00+00:00","monthly_window_start":"2026-05-21T16:00:00+00:00","daily_usage_usd":0.0000000000,"weekly_usage_usd":0.0000000000,"monthly_usage_usd":0.0000000000,"assigned_by":1,"assigned_at":"2026-05-22T04:27:09.353018+00:00","notes":"local v0.1.129 freshhost kimi validation","created_at":"2026-05-22T04:11:17.172098+00:00","updated_at":"2026-05-22T04:27:09.353018+00:00","deleted_at":null}, "key" : {"id":1,"user_id":2,"key":"sk-1779423075-ae5f2b09","name":"relay-sub-1779423075-ae5f2b09-key","group_id":2,"status":"active","created_at":"2026-05-22T04:11:15.624575+00:00","updated_at":"2026-05-22T04:27:09.353018+00:00","deleted_at":null,"ip_whitelist":null,"ip_blacklist":null,"quota":0.00000000,"quota_used":0.00000000,"expires_at":null,"last_used_at":"2026-05-22T04:22:18.019347+00:00","rate_limit_5h":0.00000000,"rate_limit_1d":0.00000000,"rate_limit_7d":0.00000000,"usage_5h":0.00000000,"usage_1d":0.00000000,"usage_7d":0.00000000,"window_5h_start":null,"window_1d_start":null,"window_7d_start":null}}

View File

@@ -0,0 +1,9 @@
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Request-Id: 3f344999-9686-4357-8673-7b56a40efb74
Date: Fri, 22 May 2026 04:27:09 GMT
Content-Length: 123

View File

@@ -0,0 +1 @@
{"data":[{"id":"kimi-k2.6","type":"model","display_name":"kimi-k2.6","created_at":"2024-01-01T00:00:00Z"}],"object":"list"}

View File

@@ -0,0 +1,10 @@
HTTP/1.1 200 OK
Content-Type: application/json
Date: Fri, 22 May 2026 04:27:10 GMT
Referrer-Policy: strict-origin-when-cross-origin
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Request-Id: b03fe0d7-0318-4d2e-9606-b0f9e1006a96
Content-Length: 611

View File

@@ -0,0 +1 @@
{"id":"msg_0aec7be5-3b0c-459c-a4a6-f5e95671ad48","model":"kimi-k2.6","object":"chat.completion","created":1779424030,"choices":[{"index":0,"message":{"role":"assistant","content":"Pong! 🏓\n\nI'm"},"finish_reason":"length"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18,"prompt_tokens_details":{"cached_tokens":0,"text_tokens":0,"audio_tokens":0,"image_tokens":0},"completion_tokens_details":{"text_tokens":0,"audio_tokens":0,"reasoning_tokens":0},"input_tokens":0,"output_tokens":0,"input_tokens_details":null,"claude_cache_creation_5_m_tokens":0,"claude_cache_creation_1_h_tokens":0}}

View File

@@ -0,0 +1 @@
{"access_closures_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","id":38,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18109","host_id":"local-v0129-kimi-fresh-1779423075","host_version":"0.1.129"},"latest_access_status":"subscription_ready","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":1,"pack":{"pack_id":"openai-cn-pack-kimi-a7m","version":"1.0.0"},"provider":{"display_name":"Kimi A7M OpenAI Compatible","platform":"openai","provider_id":"kimi-a7m"},"provider_status":"active","reconcile_runs_count":0}

View File

@@ -0,0 +1 @@
{"batch_access_status":"subscription_ready","batch_id":38,"closures_count":1,"latest_access_status":"subscription_ready","latest_closure":{"closure_type":"subscription","details_json":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"msg_e428d081-aeac-4f7d-be2a-7c6caa19d737\\\",\\\"model\\\":\\\"kimi-k2.6\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779424029,\\\"choices\\\":[{\\\"index\\\":0,\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong! 🏓\\\\n\\\\nI'm\\\"},\\\"finish_reason\\\":\\\"length\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":10,\\\"completion_tokens\\\":8,\\\"total_tokens\\\":18,\\\"prompt_tokens_details\\\":{\\\"cached_tokens\\\":0,\\\"text_tokens\\\":0,\\\"audio_tokens\\\":0,\\\"image_tokens\\\":0},\\\"completion\",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"kimi-k2.6\"],\"ok\":true,\"status_code\":200}","id":43,"status":"subscription_ready"},"pack_id":"openai-cn-pack-kimi-a7m","provider_id":"kimi-a7m"}

View File

@@ -0,0 +1 @@
{"provider_id":"kimi-a7m","mode":"subscription","available":true,"message":"latest access status: subscription_ready"}

View File

@@ -0,0 +1 @@
{"access_closures":[{"ID":43,"BatchID":38,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"msg_e428d081-aeac-4f7d-be2a-7c6caa19d737\\\",\\\"model\\\":\\\"kimi-k2.6\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779424029,\\\"choices\\\":[{\\\"index\\\":0,\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong! 🏓\\\\n\\\\nI'm\\\"},\\\"finish_reason\\\":\\\"length\\\"}],\\\"usage\\\":{\\\"prompt_tokens\\\":10,\\\"completion_tokens\\\":8,\\\"total_tokens\\\":18,\\\"prompt_tokens_details\\\":{\\\"cached_tokens\\\":0,\\\"text_tokens\\\":0,\\\"audio_tokens\\\":0,\\\"image_tokens\\\":0},\\\"completion\",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"kimi-k2.6\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":3,"id":38,"mode":"partial","pack_id":38,"provider_id":48},"items":[{"account_status":"warning","batch_id":38,"id":38,"key_fingerprint":"sha256:100830605ca92c766278d12cea58d6fc5d5eb27902a491d6c8e4fe13900a3bbe","probe_summary_json":"{\"account_id\":\"4\",\"models\":[{\"id\":\"kimi-k2.6\",\"display_name\":\"kimi-k2.6\",\"type\":\"model\"}],\"probe_advisory\":true,\"probe_message\":\"API returned 403: Forbidden\",\"probe_ok\":false,\"probe_status\":\"failed\",\"smoke_model_seen\":true,\"validation_status\":\"warning\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":65,"BatchID":38,"HostID":3,"ResourceType":"account","HostResourceID":"4","ResourceName":"kimi-a7m-01"}],"reconcile_count":0,"reconcile_runs":[]}

View File

@@ -0,0 +1,11 @@
HTTP/1.1 200 Connection established
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: application/json; charset=utf-8
date: Fri, 22 May 2026 04:27:10 GMT
via: 1.1 Caddy
x-new-api-version: v0.0.0
x-oneapi-request-id: 20260522042710560170306YIKBSr5Q
content-length: 168

View File

@@ -0,0 +1 @@
{"data":[{"id":"kimi-k2.6","object":"model","created":1626777600,"owned_by":"custom","supported_endpoint_types":["anthropic","openai"]}],"object":"list","success":true}

View File

@@ -0,0 +1,17 @@
HTTP/1.1 200 Connection established
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: application/json
date: Fri, 22 May 2026 04:27:11 GMT
req-arrive-time: 1779424031233
req-cost-time: 816
resp-start-time: 1779424032049
server: istio-envoy
set-cookie: acw_tc=180dd8dd17794240312333635e2900ebe766d23471b5c1c302fd300fee23fb;path=/;HttpOnly;Max-Age=1800
via: 1.1 Caddy
x-envoy-upstream-service-time: 812
x-new-api-version: v0.0.0
x-oneapi-request-id: 20260522042710706076631DSLRiX1b
content-length: 611

View File

@@ -0,0 +1 @@
{"id":"msg_b392a616-1575-4d7b-a4d6-7be85de3408c","model":"kimi-k2.6","object":"chat.completion","created":1779424032,"choices":[{"index":0,"message":{"role":"assistant","content":"Pong! 🏓\n\nI'm"},"finish_reason":"length"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18,"prompt_tokens_details":{"cached_tokens":0,"text_tokens":0,"audio_tokens":0,"image_tokens":0},"completion_tokens_details":{"text_tokens":0,"audio_tokens":0,"reasoning_tokens":0},"input_tokens":0,"output_tokens":0,"input_tokens_details":null,"claude_cache_creation_5_m_tokens":0,"claude_cache_creation_1_h_tokens":0}}

View File

@@ -0,0 +1,28 @@
{
"artifact_dir": "/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost",
"provider_id": "kimi-a7m",
"model_name": "kimi-k2.6",
"host_id": "local-v0129-kimi-fresh-1779423075",
"host_version_from_create_host": "0.1.129",
"batch_id": 38,
"batch_status": "succeeded",
"access_status_from_import": "subscription_ready",
"provider_status_from_import": "active",
"provider_status_latest": "active",
"latest_access_status": "subscription_ready",
"preview_available": true,
"direct_models_http200": true,
"direct_models_has_expected_model": true,
"direct_models": [
"kimi-k2.6"
],
"direct_chat_http200": true,
"direct_chat_status": 200,
"upstream_models_http200": true,
"upstream_models_has_expected_model": true,
"upstream_models": [
"kimi-k2.6"
],
"upstream_chat_status": 200,
"account_probe_summary": "{\"account_id\":\"4\",\"models\":[{\"id\":\"kimi-k2.6\",\"display_name\":\"kimi-k2.6\",\"type\":\"model\"}],\"probe_advisory\":true,\"probe_message\":\"API returned 403: Forbidden\",\"probe_ok\":false,\"probe_status\":\"failed\",\"smoke_model_seen\":true,\"validation_status\":\"warning\"}"
}

View File

@@ -11,6 +11,7 @@
- `subscription` 主链路已通过 latest fresh-host 复验:
- MiniMax 53hk`artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json`
- DeepSeek 2166`artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json`
- Kimi A7Mlocal host `v0.1.129``artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/21-summary.json`
- `self_service` 主链路已通过 latest-head 标准 fresh-host 复验:
- `artifacts/real-host-acceptance/20260521_210403/05-import.json`
- `artifacts/real-host-acceptance/20260521_210403/07-access-status.json`
@@ -64,6 +65,10 @@
- 主目录 `artifacts/real-host-acceptance/` 当前只保留最终证据
- 历史失败/半成功/试错样本已迁到 `artifacts/real-host-acceptance-archive/`
- 分类规则见:`docs/REAL_HOST_ARTIFACT_RETENTION.md`
13. relay-manager latest-head 已收口 Kimi A7M 两段竞态
- account test 首次 `403 Forbidden` 已降级为 advisory warning只要 `/models` 已命中 `smoke_test_model`,不会再把 batch 误判为 blocking failure
- access closure 对导入后瞬时 `503 / no available accounts` 增加短暂 completion retry避免宿主异步 probe / account warm-up 窗口把真实可用链路误记成 `broken`
- `20260522_122706_local_v0129_kimi_a7m_subscription_freshhost` 已证明:在修复后的 relay-manager + patched host 组合下,`kimi-a7m / kimi-k2.6` 可落到 `batch_status=succeeded``provider_status=active``latest_access_status=subscription_ready`
## 已验证门禁
@@ -106,6 +111,11 @@
- official MiniMax 模板 live 样本
- 模板链路打通,但当前验证 key 命中 upstream `429`
7. `artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost`
- latest-head relay-manager 对 patched host `v0.1.129` 的 Kimi A7M `subscription` 最终成功样本
- `21-summary.json` 已到 `batch_status=succeeded``provider_status=active`
- `account_probe_summary` 明确记录 `probe_advisory=true``validation_status=warning`,证明 403 probe race 已被 relay-manager 正确降级
## 剩余项P2 / 运营前置,不阻塞按 PRD 首版范围上线)
1. 运营前置

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"sub2api-cn-relay-manager/internal/host/sub2api"
)
@@ -11,6 +12,9 @@ import (
const (
ModeSubscription = "subscription"
ModeSelfService = "self_service"
gatewayCompletionRetryAttempts = 3
gatewayCompletionRetryDelay = 300 * time.Millisecond
)
type SubscriptionTarget struct {
@@ -96,7 +100,7 @@ func (s *Service) Close(ctx context.Context, req ClosureRequest) (sub2api.Gatewa
return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err)
}
if result.OK && result.HasExpectedModel && strings.TrimSpace(req.ExpectedModel) != "" {
completion, err := s.host.CheckGatewayCompletion(ctx, sub2api.GatewayCompletionCheckRequest{
completion, err := s.checkGatewayCompletionWithRetry(ctx, sub2api.GatewayCompletionCheckRequest{
APIKey: probeAPIKey,
Model: req.ExpectedModel,
Prompt: req.Prompt,
@@ -112,3 +116,39 @@ func (s *Service) Close(ctx context.Context, req ClosureRequest) (sub2api.Gatewa
}
return result, nil
}
func (s *Service) checkGatewayCompletionWithRetry(ctx context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) {
var last sub2api.GatewayCompletionResult
for attempt := 1; attempt <= gatewayCompletionRetryAttempts; attempt++ {
completion, err := s.host.CheckGatewayCompletion(ctx, req)
if err != nil {
return sub2api.GatewayCompletionResult{}, err
}
last = completion
if completion.OK || !isTransientGatewayCompletionFailure(completion) || attempt == gatewayCompletionRetryAttempts {
return completion, nil
}
timer := time.NewTimer(gatewayCompletionRetryDelay)
select {
case <-ctx.Done():
timer.Stop()
return last, ctx.Err()
case <-timer.C:
}
}
return last, nil
}
func isTransientGatewayCompletionFailure(result sub2api.GatewayCompletionResult) bool {
if result.OK {
return false
}
if result.StatusCode != 0 && result.StatusCode != 429 && result.StatusCode != 502 && result.StatusCode != 503 && result.StatusCode != 504 {
return false
}
body := strings.ToLower(strings.TrimSpace(result.BodyPreview))
return strings.Contains(body, "service temporarily unavailable") ||
strings.Contains(body, "no available accounts") ||
strings.Contains(body, "temporar") ||
strings.Contains(body, "try again")
}

View File

@@ -114,16 +114,47 @@ func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) {
}
}
func TestServiceCloseRetriesTransientGatewayCompletionFailure(t *testing.T) {
host := &fakeClosureHost{
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"kimi-k2.6"}},
completionResults: []sub2api.GatewayCompletionResult{
{OK: false, StatusCode: 503, ContentType: "application/json", BodyPreview: `{"error":{"message":"Service temporarily unavailable","type":"api_error"}}`},
{OK: true, StatusCode: 200, ContentType: "application/json"},
},
managedAccess: map[string]sub2api.SubscriptionAccessRef{
"user-1": {UserID: "host-user-1", APIKey: "managed-user-key"},
},
}
result, err := NewService(host).Close(context.Background(), ClosureRequest{
Mode: "subscription",
GroupID: "group-1",
ExpectedModel: "kimi-k2.6",
Subscriptions: []SubscriptionTarget{{UserID: "user-1", DurationDays: 30}},
})
if err != nil {
t.Fatalf("Close() error = %v", err)
}
if !result.CompletionOK || result.CompletionStatus != 200 {
t.Fatalf("completion result = %+v, want retried success", result)
}
if host.completionCalls != 2 {
t.Fatalf("completion calls = %d, want 2", host.completionCalls)
}
}
type fakeClosureHost struct {
assigned []sub2api.AssignSubscriptionRequest
managedAccess map[string]sub2api.SubscriptionAccessRef
assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest
gatewayResult sub2api.GatewayAccessResult
gatewayErr error
completionProbe sub2api.GatewayCompletionCheckRequest
completionResult sub2api.GatewayCompletionResult
completionErr error
assigned []sub2api.AssignSubscriptionRequest
managedAccess map[string]sub2api.SubscriptionAccessRef
assignErr error
gatewayProbe sub2api.GatewayAccessCheckRequest
gatewayResult sub2api.GatewayAccessResult
gatewayErr error
completionProbe sub2api.GatewayCompletionCheckRequest
completionCalls int
completionResults []sub2api.GatewayCompletionResult
completionResult sub2api.GatewayCompletionResult
completionErr error
}
func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) {
@@ -151,8 +182,16 @@ func (f *fakeClosureHost) CheckGatewayAccess(_ context.Context, req sub2api.Gate
func (f *fakeClosureHost) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) {
f.completionProbe = req
f.completionCalls++
if f.completionErr != nil {
return sub2api.GatewayCompletionResult{}, f.completionErr
}
if len(f.completionResults) > 0 {
idx := f.completionCalls - 1
if idx >= len(f.completionResults) {
idx = len(f.completionResults) - 1
}
return f.completionResults[idx], nil
}
return f.completionResult, nil
}

View File

@@ -118,6 +118,16 @@ func isAdvisoryAccountProbeFailure(probe sub2api.ProbeResult) bool {
return true
}
// OpenAI-compatible third-party upstreams such as Kimi/DeepSeek may
// create accounts and expose /models immediately, but the host's
// asynchronous /responses capability probe can complete slightly later.
// During that race window, the first /accounts/:id/test still takes the
// default /responses path and returns a plain 403 Forbidden even though
// the actual chat/completions route already works.
if strings.Contains(message, "api returned 403: forbidden") {
return true
}
if !strings.Contains(message, "responses api") {
return false
}

View File

@@ -244,6 +244,104 @@ func TestImportServiceTreatsTransientProbeFailureAsAdvisoryWhenGatewaySucceeds(t
}
}
func TestImportServiceTreatsForbiddenProbeRaceAsAdvisoryWhenGatewaySucceeds(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 403: Forbidden",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
CompletionType: "application/json",
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning {
t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning)
}
}
func TestImportServiceRetriesTransientGatewayCompletionFailure(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "kimi-a7m-01"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 403: Forbidden",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
},
completionResults: []sub2api.GatewayCompletionResult{
{OK: false, StatusCode: 503, ContentType: "application/json", BodyPreview: `{"error":{"message":"Service temporarily unavailable","type":"api_error"}}`},
{OK: true, StatusCode: 200, ContentType: "application/json"},
},
}
report, err := NewImportService(host).Import(context.Background(), ImportRequest{
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
Keys: []string{"key-1"},
})
if err != nil {
t.Fatalf("Import() error = %v", err)
}
if report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusSucceeded)
}
if report.ProviderStatus != ProviderStatusActive {
t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusActive)
}
if report.AccessStatus != AccessStatusSelfServiceReady {
t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusSelfServiceReady)
}
if !report.Gateway.CompletionOK || report.Gateway.CompletionStatus != 200 {
t.Fatalf("Gateway completion = %+v, want retried success", report.Gateway)
}
if host.completionCalls != 2 {
t.Fatalf("completion calls = %d, want 2", host.completionCalls)
}
}
func TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) {
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}},
@@ -520,6 +618,8 @@ type fakeHostAdapter struct {
updateChannelID string
updateChannelReq sub2api.CreateChannelRequest
callSequence []string
completionCalls int
completionResults []sub2api.GatewayCompletionResult
completionResult sub2api.GatewayCompletionResult
completionErr error
testedModels map[string]string
@@ -620,9 +720,17 @@ func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.Gate
func (f *fakeHostAdapter) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) {
f.callSequence = append(f.callSequence, "completion")
f.completionProbe = req
f.completionCalls++
if f.completionErr != nil {
return sub2api.GatewayCompletionResult{}, f.completionErr
}
if len(f.completionResults) > 0 {
idx := f.completionCalls - 1
if idx >= len(f.completionResults) {
idx = len(f.completionResults) - 1
}
return f.completionResults[idx], nil
}
if f.completionResult.StatusCode == 0 && !f.completionResult.OK {
return sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"}, nil
}

View File

@@ -236,6 +236,70 @@ func TestRuntimeImportServicePersistsWarningAccountStatusForAdvisoryProbeFailure
}
}
func TestRuntimeImportServicePersistsWarningAccountStatusForForbiddenProbeRace(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)
seedProvisionHost(t, store, "host-1", "https://sub2api.example.com")
host := &fakeHostAdapter{
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}},
testResults: map[string]sub2api.ProbeResult{
"account_1": {
OK: false,
Status: "failed",
Message: "API returned 403: Forbidden",
},
},
models: map[string][]sub2api.AccountModel{
"account_1": {{ID: "deepseek-chat"}},
},
gatewayResult: sub2api.GatewayAccessResult{
OK: true,
StatusCode: 200,
HasExpectedModel: true,
Models: []string{"deepseek-chat"},
CompletionOK: true,
CompletionStatus: 200,
},
}
svc := NewRuntimeImportService(store, host)
result, err := svc.Import(context.Background(), RuntimeImportRequest{
HostID: "host-1",
HostBaseURL: "https://sub2api.example.com",
Pack: pack.LoadedPack{
Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"},
Checksum: "checksum-1",
},
Provider: sampleProviderManifest(),
Mode: ImportModePartial,
Keys: []string{"key-1"},
Access: AccessRequest{
Mode: AccessModeSelfService,
ProbeAPIKey: "user-key",
},
})
if err != nil {
t.Fatalf("RuntimeImportService.Import() error = %v", err)
}
if result.Report.BatchStatus != BatchStatusSucceeded {
t.Fatalf("BatchStatus = %q, want %q", result.Report.BatchStatus, BatchStatusSucceeded)
}
var accountStatus string
var summary string
if err := store.SQLDB().QueryRowContext(context.Background(), "SELECT account_status, probe_summary_json FROM import_batch_items WHERE batch_id = ? ORDER BY id LIMIT 1", result.BatchID).Scan(&accountStatus, &summary); err != nil {
t.Fatalf("query import batch item: %v", err)
}
if accountStatus != AccountStatusWarning {
t.Fatalf("account_status = %q, want %q", accountStatus, AccountStatusWarning)
}
if !strings.Contains(summary, "\"probe_advisory\":true") {
t.Fatalf("probe_summary_json = %s, want probe_advisory=true", summary)
}
}
func TestRuntimeImportServicePersistsPartialManagedResourcesOnAccessFailure(t *testing.T) {
store := openProvisionTestStore(t)
defer closeProvisionTestStore(t, store)