From 9134afed9f79d54bf40550fff7acdc573a185597 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Fri, 22 May 2026 12:33:12 +0800 Subject: [PATCH] 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. --- .../00-local-key-source.json | 9 ++ .../01-runtime-context.json | 9 ++ .../01a-create-host.json | 1 + .../02-import.headers.txt | 5 + .../03-import.body.json | 1 + .../04-batch-detail-initial.json | 1 + .../05-subscription-access-prep.sql | 25 ++++ .../06-subscription-access-prep.psql.txt | 5 + .../07-redis-targeted-invalidation.txt | 4 + .../08-subscription-group-state.json | 1 + .../09-models.headers.txt | 9 ++ .../10-models.body.json | 1 + .../11-chat.headers.txt | 10 ++ .../12-chat.body.json | 1 + .../13-provider-status.json | 1 + .../14-access-status.json | 1 + .../15-access-preview.json | 1 + .../16-batch-detail-final.json | 1 + .../17-upstream-models.headers.txt | 11 ++ .../18-upstream-models.body.json | 1 + .../19-upstream-chat.headers.txt | 17 +++ .../20-upstream-chat.body.txt | 1 + .../21-summary.json | 28 +++++ docs/EXECUTION_BOARD.md | 10 ++ internal/access/closure.go | 42 ++++++- internal/access/closure_test.go | 57 +++++++-- internal/provision/import_service.go | 10 ++ internal/provision/import_service_test.go | 108 ++++++++++++++++++ .../provision/runtime_import_service_test.go | 64 +++++++++++ 29 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/00-local-key-source.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01-runtime-context.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01a-create-host.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/02-import.headers.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/03-import.body.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/04-batch-detail-initial.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/05-subscription-access-prep.sql create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/06-subscription-access-prep.psql.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/07-redis-targeted-invalidation.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/08-subscription-group-state.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/09-models.headers.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/10-models.body.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/11-chat.headers.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/12-chat.body.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/13-provider-status.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/14-access-status.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/15-access-preview.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/16-batch-detail-final.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/17-upstream-models.headers.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/18-upstream-models.body.json create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/19-upstream-chat.headers.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/20-upstream-chat.body.txt create mode 100644 artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/21-summary.json diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/00-local-key-source.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/00-local-key-source.json new file mode 100644 index 00000000..01b0f90b --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/00-local-key-source.json @@ -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" +} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01-runtime-context.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01-runtime-context.json new file mode 100644 index 00000000..3e242482 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01-runtime-context.json @@ -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" +} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01a-create-host.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01a-create-host.json new file mode 100644 index 00000000..5612d6f0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/01a-create-host.json @@ -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}} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/02-import.headers.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/02-import.headers.txt new file mode 100644 index 00000000..2b2032a6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/02-import.headers.txt @@ -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 + diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/03-import.body.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/03-import.body.json new file mode 100644 index 00000000..e53250a6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/03-import.body.json @@ -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"} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/04-batch-detail-initial.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/04-batch-detail-initial.json new file mode 100644 index 00000000..aab78728 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/04-batch-detail-initial.json @@ -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":[]} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/05-subscription-access-prep.sql b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/05-subscription-access-prep.sql new file mode 100644 index 00000000..b4cb4006 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/05-subscription-access-prep.sql @@ -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; diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/06-subscription-access-prep.psql.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/06-subscription-access-prep.psql.txt new file mode 100644 index 00000000..894b2e3a --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/06-subscription-access-prep.psql.txt @@ -0,0 +1,5 @@ +BEGIN +UPDATE 1 +UPDATE 1 +INSERT 0 1 +COMMIT diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/07-redis-targeted-invalidation.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/07-redis-targeted-invalidation.txt new file mode 100644 index 00000000..e64b7ba7 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/07-redis-targeted-invalidation.txt @@ -0,0 +1,4 @@ +auth_cache_key=apikey:auth:fc26062e46b5878e43d427af92c522baefef87a59512163b32da3189005b523a +balance_cache_key=billing:balance:2 +subscription_cache_key=billing:sub:2:2 +0 diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/08-subscription-group-state.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/08-subscription-group-state.json new file mode 100644 index 00000000..94d70fe3 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/08-subscription-group-state.json @@ -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}} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/09-models.headers.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/09-models.headers.txt new file mode 100644 index 00000000..6c0299fb --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/09-models.headers.txt @@ -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 + diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/10-models.body.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/10-models.body.json new file mode 100644 index 00000000..b9de987c --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/10-models.body.json @@ -0,0 +1 @@ +{"data":[{"id":"kimi-k2.6","type":"model","display_name":"kimi-k2.6","created_at":"2024-01-01T00:00:00Z"}],"object":"list"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/11-chat.headers.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/11-chat.headers.txt new file mode 100644 index 00000000..87bb4e8c --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/11-chat.headers.txt @@ -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 + diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/12-chat.body.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/12-chat.body.json new file mode 100644 index 00000000..d668b3da --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/12-chat.body.json @@ -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}} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/13-provider-status.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/13-provider-status.json new file mode 100644 index 00000000..f8253a26 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/13-provider-status.json @@ -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} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/14-access-status.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/14-access-status.json new file mode 100644 index 00000000..1c1f93f1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/14-access-status.json @@ -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"} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/15-access-preview.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/15-access-preview.json new file mode 100644 index 00000000..56e78371 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/15-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"kimi-a7m","mode":"subscription","available":true,"message":"latest access status: subscription_ready"} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/16-batch-detail-final.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/16-batch-detail-final.json new file mode 100644 index 00000000..aab78728 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/16-batch-detail-final.json @@ -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":[]} diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/17-upstream-models.headers.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/17-upstream-models.headers.txt new file mode 100644 index 00000000..8f691726 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/17-upstream-models.headers.txt @@ -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 + diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/18-upstream-models.body.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/18-upstream-models.body.json new file mode 100644 index 00000000..8369a64f --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/18-upstream-models.body.json @@ -0,0 +1 @@ +{"data":[{"id":"kimi-k2.6","object":"model","created":1626777600,"owned_by":"custom","supported_endpoint_types":["anthropic","openai"]}],"object":"list","success":true} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/19-upstream-chat.headers.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/19-upstream-chat.headers.txt new file mode 100644 index 00000000..bb1dcd70 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/19-upstream-chat.headers.txt @@ -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 + diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/20-upstream-chat.body.txt b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/20-upstream-chat.body.txt new file mode 100644 index 00000000..568634fe --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/20-upstream-chat.body.txt @@ -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}} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/21-summary.json b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/21-summary.json new file mode 100644 index 00000000..d5e573c9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260522_122706_local_v0129_kimi_a7m_subscription_freshhost/21-summary.json @@ -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\"}" +} diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index c4e19501..35e5eee5 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -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 A7M(local 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. 运营前置 diff --git a/internal/access/closure.go b/internal/access/closure.go index 4142fc6d..be469515 100644 --- a/internal/access/closure.go +++ b/internal/access/closure.go @@ -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") +} diff --git a/internal/access/closure_test.go b/internal/access/closure_test.go index cedf180a..ed0c2c8a 100644 --- a/internal/access/closure_test.go +++ b/internal/access/closure_test.go @@ -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 } diff --git a/internal/provision/import_service.go b/internal/provision/import_service.go index e9918b0d..c28e85e7 100644 --- a/internal/provision/import_service.go +++ b/internal/provision/import_service.go @@ -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 } diff --git a/internal/provision/import_service_test.go b/internal/provision/import_service_test.go index c4809cde..e71a61d5 100644 --- a/internal/provision/import_service_test.go +++ b/internal/provision/import_service_test.go @@ -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 } diff --git a/internal/provision/runtime_import_service_test.go b/internal/provision/runtime_import_service_test.go index c1151fde..cc17c14c 100644 --- a/internal/provision/runtime_import_service_test.go +++ b/internal/provision/runtime_import_service_test.go @@ -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)