From 3ba3244ea6282980e4f685a5e9a7ceac7356b2ca Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Thu, 21 May 2026 21:19:19 +0800 Subject: [PATCH] fix fresh-host acceptance and document real-host debugging learnings --- ...521_064910_completion_smoke_calibration.md | 47 ++++ .../00-local-key-source.json | 6 + .../01-runtime-context.json | 13 + .../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 | 46 ++++ .../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 | 12 + .../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 | 16 ++ .../18-upstream-models.body.json | 1 + .../19-upstream-chat.headers.txt | 16 ++ .../20-upstream-chat.body.txt | 1 + .../21-summary.json | 1 + .../00-local-key-source.json | 6 + .../01-runtime-context.json | 13 + .../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 | 46 ++++ .../06-subscription-access-prep.psql.txt | 3 + .../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 | 9 + .../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 | 9 + .../18-upstream-models.body.json | 1 + .../19-upstream-chat.headers.txt | 9 + .../20-upstream-chat.body.txt | 1 + .../21-summary.json | 1 + .../20260521_210403/00-artifact-guide.txt | 30 +++ .../20260521_210403/01-create-host.json | 1 + .../20260521_210403/02-probe-host.json | 1 + .../20260521_210403/03-install-pack.json | 1 + .../20260521_210403/04-preview-import.json | 1 + .../20260521_210403/05-import.json | 1 + .../05a-batch-detail-pre-access.json | 1 + .../20260521_210403/06-access-preview.json | 1 + .../20260521_210403/07-access-status.json | 1 + .../20260521_210403/08-provider-status.json | 1 + .../20260521_210403/09-reconcile.json | 1 + .../20260521_210403/10-batch-detail.json | 1 + .../20260521_210403/11-rollback.json | 1 + docs/EXECUTION_BOARD.md | 74 ++++-- docs/PRODUCTION_CLOSURE_BOARD.md | 42 ++-- docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md | 19 ++ docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md | 8 +- docs/SOURCE_OF_TRUTH.md | 40 ++- internal/access/closure.go | 18 ++ internal/access/closure_test.go | 35 ++- internal/app/app_test.go | 7 +- internal/app/http_api.go | 28 ++- internal/host/sub2api/accounts.go | 90 +++++-- internal/host/sub2api/client.go | 17 +- internal/host/sub2api/gateway_probe.go | 54 +++- internal/host/sub2api/list_resources.go | 65 +++-- internal/host/sub2api/sub2api_test.go | 145 ++++++++++- internal/pack/loader.go | 13 +- internal/pack/loader_test.go | 17 ++ .../batch_detail_and_reconcile_service.go | 143 +++++++++-- internal/provision/import_service.go | 81 +++++- internal/provision/import_service_test.go | 156 +++++++++++- internal/provision/reconcile_service_test.go | 232 ++++++++++++++++++ internal/provision/runtime_import_service.go | 23 +- .../provision/runtime_import_service_test.go | 65 +++++ scripts/import_remote43_provider.sh | 89 ++++++- scripts/test_real_host_scripts.sh | 38 ++- tests/integration/host_stub_test.go | 24 +- 85 files changed, 1721 insertions(+), 162 deletions(-) create mode 100644 artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/00-local-key-source.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01-runtime-context.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01a-create-host.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/02-import.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/03-import.body.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/04-batch-detail-initial.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/05-subscription-access-prep.sql create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/06-subscription-access-prep.psql.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/07-redis-targeted-invalidation.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/08-subscription-group-state.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/09-models.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/10-models.body.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/11-chat.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/12-chat.body.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/13-provider-status.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/14-access-status.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/15-access-preview.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/16-batch-detail-final.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/17-upstream-models.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/18-upstream-models.body.json create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/19-upstream-chat.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/20-upstream-chat.body.txt create mode 100644 artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/00-local-key-source.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01-runtime-context.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01a-create-host.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/02-import.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/03-import.body.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/04-batch-detail-initial.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/05-subscription-access-prep.sql create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/06-subscription-access-prep.psql.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/07-redis-targeted-invalidation.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/08-subscription-group-state.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/09-models.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/10-models.body.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/11-chat.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/12-chat.body.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/13-provider-status.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/14-access-status.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/15-access-preview.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/16-batch-detail-final.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/17-upstream-models.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/18-upstream-models.body.json create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/19-upstream-chat.headers.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/20-upstream-chat.body.txt create mode 100644 artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/00-artifact-guide.txt create mode 100644 artifacts/real-host-acceptance/20260521_210403/01-create-host.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/02-probe-host.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/03-install-pack.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/04-preview-import.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/05-import.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/05a-batch-detail-pre-access.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/06-access-preview.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/07-access-status.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/08-provider-status.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/09-reconcile.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/10-batch-detail.json create mode 100644 artifacts/real-host-acceptance/20260521_210403/11-rollback.json diff --git a/artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md b/artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md new file mode 100644 index 00000000..c16b46c0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md @@ -0,0 +1,47 @@ +# 2026-05-21 completion smoke calibration + +## Scope +- CRM: `http://127.0.0.1:18100` +- Fresh host: `http://127.0.0.1:18097` +- Script head: `scripts/import_remote43_provider.sh` with managed subscription probe key + local `PACK_PATH` default fix + +## Latest real-host artifacts +- DeepSeek: `artifacts/real-host-acceptance/20260521_064403_remote43_deepseek_key_import` +- MiniMax: `artifacts/real-host-acceptance/20260521_064454_remote43_minimax_key_import` + +## Verified facts +1. Acceptance-harness false negative fixed + - Both latest artifacts now show `09-models.headers.txt = HTTP 200` + - Both latest artifacts show correct target models in `10-models.body.json` + - DeepSeek `08-subscription-group-state.json` and MiniMax counterpart now show real managed user / subscription / key bound to the imported subscription group when the script points at the correct fresh-host Postgres/Redis containers. + +2. CRM/import closure still reports success + - DeepSeek latest import: `batch_id=11`, `access_status=subscription_ready`, `latest_access_status=subscription_ready` + - MiniMax latest import: `batch_id=12`, `access_status=subscription_ready`, `latest_access_status=subscription_ready` + +3. Real completion smoke still fails + - DeepSeek host-managed key: `/v1/chat/completions` returns `502 upstream_error` (`Upstream service temporarily unavailable`) + - MiniMax host-managed key: `/v1/chat/completions` returns `502 upstream_error` (`Upstream access forbidden, please contact administrator`) + +4. Upstream direct probe split proves two different blocker classes + - DeepSeek upstream direct probe against `https://aitoken.quanfuli.cn/v1` + - `/models`: HTTP 200 + - `/chat/completions`: HTTP 200 + - Therefore the host-side 502 is not explained by an unusable upstream key alone. + - MiniMax upstream direct probe against `https://v2.aicodee.com/v1` + - `/models`: HTTP 200 + - `/chat/completions`: HTTP 403 `insufficient_user_quota` + - Therefore the current MiniMax validation key is not usable for real completion traffic. + +## Current conclusion +- `subscription_ready` + `/v1/models` success is no longer enough to claim full provider acceptance. +- The previous script-level `401 Unauthorized` diagnosis is closed. +- The active real-environment blocker has moved to completion-smoke truth: + - DeepSeek: host-side completion compatibility still unresolved + - MiniMax: validation key/quota not sufficient for completion traffic + +## Gate impact +- Code gate: PASS +- Real import/access-closure gate: PASS +- Real completion-smoke gate: FAIL +- Overall: not `APPROVED` for full end-to-end provider traffic acceptance diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/00-local-key-source.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/00-local-key-source.json new file mode 100644 index 00000000..9a7f3b4b --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/00-local-key-source.json @@ -0,0 +1,6 @@ +{ + "source": "env:MINIMAX_53HK_API_KEY", + "provider_id": "minimax", + "upstream_key_prefix": "sk-4175b7d84", + "upstream_key_suffix": "42d776" +} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01-runtime-context.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01-runtime-context.json new file mode 100644 index 00000000..3ff95540 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01-runtime-context.json @@ -0,0 +1,13 @@ +{ + "crm_base": "http://127.0.0.1:18106", + "host_base": "http://127.0.0.1:18097", + "crm_host_base": "http://127.0.0.1:18097", + "provider_id": "minimax", + "subscription_user_id": "19", + "subscription_user_key_prefix": "sk-177936206", + "subscription_group_id": "6", + "admin_user_id": "1", + "managed_user_email": "19-a849d81aeef97389@sub2api.local", + "managed_user_id": "", + "managed_probe_key_prefix": "sk-relay-a849d81ae" +} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01a-create-host.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01a-create-host.json new file mode 100644 index 00000000..613eba80 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/01a-create-host.json @@ -0,0 +1 @@ +{"host_id": "remote43-fresh18097-deepseek-1779280533", "base_url": "http://127.0.0.1:18097", "host_version": "0.1.126", "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/20260521_191418_remote43_minimax_key_import/02-import.headers.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/02-import.headers.txt new file mode 100644 index 00000000..1f3570d0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/02-import.headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Date: Thu, 21 May 2026 11:14:35 GMT +Content-Length: 1023 + diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/03-import.body.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/03-import.body.json new file mode 100644 index 00000000..69b1b433 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/03-import.body.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"subscription_ready","accounts_count":1,"batch_id":22,"batch_status":"succeeded","channel":{"id":"5","name":"MiniMax 默认渠道-subscription"},"gateway":{"ok":true,"status_code":200,"models":["MiniMax-M2.5-highspeed","MiniMax-M2.7-highspeed"],"has_expected_model":true,"completion_ok":true,"completion_status":200,"completion_content_type":"text/event-stream","completion_body_preview":"{\"id\":\"065e1c18a1f74501bffedc6a89851bcb\",\"object\":\"chat.completion\",\"created\":1779362075,\"model\":\"MiniMax-M2.7-highspeed\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Pong! I’m here. How can I help you today?\",\"reasoning_content\":\"The user says \\\"ping\\\". Likely they want a simple ping response. They could be checking if the bot is alive. The best response: \\\"Pong!\\\" or somethin"},"group":{"id":"6","name":"MiniMax 默认分组-subscription"},"plan":{"id":"5","name":"MiniMax 默认套餐-subscription"},"provider_status":"active"} diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/04-batch-detail-initial.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/04-batch-detail-initial.json new file mode 100644 index 00000000..540aa81b --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/04-batch-detail-initial.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":22,"BatchID":22,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"065e1c18a1f74501bffedc6a89851bcb\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779362075,\\\"model\\\":\\\"MiniMax-M2.7-highspeed\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong! I’m here. How can I help you today?\\\",\\\"reasoning_content\\\":\\\"The user says \\\\\\\"ping\\\\\\\". Likely they want a simple ping response. They could be checking if the bot is alive. The best response: \\\\\\\"Pong!\\\\\\\" or somethin\",\"completion_status\":200,\"completion_type\":\"text/event-stream\",\"has_expected_model\":true,\"models\":[\"MiniMax-M2.5-highspeed\",\"MiniMax-M2.7-highspeed\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":1,"id":22,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":22,"id":22,"key_fingerprint":"sha256:3285f7116ec262d059c83dbdaf041eb7fcf6d04e2abdacbbff94891648beaccd","probe_summary_json":"{\"account_id\":\"37\",\"models\":[{\"id\":\"MiniMax-M2.7-highspeed\",\"display_name\":\"MiniMax-M2.7-highspeed\",\"type\":\"model\"},{\"id\":\"MiniMax-M2.5-highspeed\",\"display_name\":\"MiniMax-M2.5-highspeed\",\"type\":\"model\"}],\"probe_advisory\":false,\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"smoke_model_seen\":true,\"validation_status\":\"passed\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":28,"BatchID":22,"HostID":1,"ResourceType":"account","HostResourceID":"37","ResourceName":"minimax-01"}],"reconcile_count":0,"reconcile_runs":[]} diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/05-subscription-access-prep.sql b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/05-subscription-access-prep.sql new file mode 100644 index 00000000..68023352 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/05-subscription-access-prep.sql @@ -0,0 +1,46 @@ +BEGIN; +UPDATE users +SET balance = CASE WHEN balance < 10 THEN 10 ELSE balance END, + updated_at = now() +WHERE id = 19; +UPDATE api_keys +SET group_id = 6, + updated_at = now() +WHERE key = 'sk-1779362063-a93693cd'; +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 ( + 19, + 6, + now(), + now() + interval '30 days', + 'active', + 1, + now(), + 'hermes remote subscription 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; \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/06-subscription-access-prep.psql.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/06-subscription-access-prep.psql.txt new file mode 100644 index 00000000..894b2e3a --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/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/20260521_191418_remote43_minimax_key_import/07-redis-targeted-invalidation.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/07-redis-targeted-invalidation.txt new file mode 100644 index 00000000..ab302048 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/07-redis-targeted-invalidation.txt @@ -0,0 +1,4 @@ +auth_cache_key=apikey:auth:ade6d1c548914a154f4c153434063d8d14ea39560df4980c0839bfbd3aa9caef +balance_cache_key=billing:balance:19 +subscription_cache_key=billing:sub:19:6 +0 diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/08-subscription-group-state.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/08-subscription-group-state.json new file mode 100644 index 00000000..3b35e816 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/08-subscription-group-state.json @@ -0,0 +1 @@ +{"group_id" : 6, "group" : {"id":6,"name":"MiniMax 默认分组","description":"","rate_multiplier":1.0000,"is_exclusive":false,"status":"active","created_at":"2026-05-19T03:16:42.338399+00:00","updated_at":"2026-05-19T03:16:42.338399+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":25,"user_id":19,"group_id":6,"starts_at":"2026-05-21T11:14:38.431671+00:00","expires_at":"2026-06-20T11:14:38.431671+00:00","status":"active","daily_window_start":null,"weekly_window_start":null,"monthly_window_start":null,"daily_usage_usd":0.0000000000,"weekly_usage_usd":0.0000000000,"monthly_usage_usd":0.0000000000,"assigned_by":1,"assigned_at":"2026-05-21T11:14:38.431671+00:00","notes":"hermes remote subscription validation","created_at":"2026-05-21T11:14:38.431671+00:00","updated_at":"2026-05-21T11:14:38.431671+00:00","deleted_at":null}, "key" : {"id":18,"user_id":19,"key":"sk-1779362063-a93693cd","name":"relay-sub-1779362063-a93693cd-key","group_id":6,"status":"active","created_at":"2026-05-21T11:14:24.788861+00:00","updated_at":"2026-05-21T11:14:38.431671+00:00","deleted_at":null,"ip_whitelist":null,"ip_blacklist":null,"quota":0.00000000,"quota_used":0.00000000,"expires_at":null,"last_used_at":null,"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/20260521_191418_remote43_minimax_key_import/09-models.headers.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/09-models.headers.txt new file mode 100644 index 00000000..464cb244 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/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: 3be00b1d-eff7-489e-82f8-ed93f575ac58 +Date: Thu, 21 May 2026 11:14:41 GMT +Content-Length: 272 + diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/10-models.body.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/10-models.body.json new file mode 100644 index 00000000..2741b29d --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/10-models.body.json @@ -0,0 +1 @@ +{"data":[{"id":"MiniMax-M2.5-highspeed","type":"model","display_name":"MiniMax-M2.5-highspeed","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7-highspeed","type":"model","display_name":"MiniMax-M2.7-highspeed","created_at":"2024-01-01T00:00:00Z"}],"object":"list"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/11-chat.headers.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/11-chat.headers.txt new file mode 100644 index 00000000..daee18c1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/11-chat.headers.txt @@ -0,0 +1,12 @@ +HTTP/1.1 200 OK +Cache-Control: max-age=0 +Content-Type: text/event-stream +Date: Thu, 21 May 2026 11:14:44 GMT +Referrer-Policy: strict-origin-when-cross-origin +Vary: Accept-Encoding +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-Request-Id: 77d9aaaa-78a0-4f5b-9a6e-f4358cf1d91e +X-Request-Id: 88f4fe1e-697b-4e1f-a49e-59b008fc604f +Content-Length: 499 + diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/12-chat.body.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/12-chat.body.json new file mode 100644 index 00000000..5b0a58c0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/12-chat.body.json @@ -0,0 +1 @@ +{"id":"065e1c254498e0be5bbd58286d1098bb","object":"chat.completion","created":1779362088,"model":"MiniMax-M2.7-highspeed","choices":[{"index":0,"message":{"role":"assistant","content":"pong! 👋\n\nI'm here and ready to help. What can I do for you today?","reasoning_content":"The user simply said \"ping\". This is likely a test to see if I'm responsive. I should respond in a friendly, helpful way."},"finish_reason":"stop"}],"usage":{"prompt_tokens":42,"completion_tokens":52,"total_tokens":94}} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/13-provider-status.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/13-provider-status.json new file mode 100644 index 00000000..9aa8dfc6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/13-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","id":22,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18097","host_id":"remote43-fresh18097-deepseek-1779280533","host_version":"0.1.126"},"latest_access_status":"subscription_ready","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":1,"pack":{"pack_id":"openai-cn-pack","version":"1.0.1"},"provider":{"display_name":"MiniMax OpenAI Compatible","platform":"openai","provider_id":"minimax"},"provider_status":"active","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/14-access-status.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/14-access-status.json new file mode 100644 index 00000000..9b88baf1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/14-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"subscription_ready","batch_id":22,"closures_count":1,"latest_access_status":"subscription_ready","latest_closure":{"closure_type":"subscription","details_json":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"065e1c18a1f74501bffedc6a89851bcb\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779362075,\\\"model\\\":\\\"MiniMax-M2.7-highspeed\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong! I’m here. How can I help you today?\\\",\\\"reasoning_content\\\":\\\"The user says \\\\\\\"ping\\\\\\\". Likely they want a simple ping response. They could be checking if the bot is alive. The best response: \\\\\\\"Pong!\\\\\\\" or somethin\",\"completion_status\":200,\"completion_type\":\"text/event-stream\",\"has_expected_model\":true,\"models\":[\"MiniMax-M2.5-highspeed\",\"MiniMax-M2.7-highspeed\"],\"ok\":true,\"status_code\":200}","id":22,"status":"subscription_ready"},"pack_id":"openai-cn-pack","provider_id":"minimax"} diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/15-access-preview.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/15-access-preview.json new file mode 100644 index 00000000..1cef7a29 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/15-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"minimax","mode":"subscription","available":true,"message":"latest access status: subscription_ready"} diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/16-batch-detail-final.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/16-batch-detail-final.json new file mode 100644 index 00000000..540aa81b --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/16-batch-detail-final.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":22,"BatchID":22,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"065e1c18a1f74501bffedc6a89851bcb\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779362075,\\\"model\\\":\\\"MiniMax-M2.7-highspeed\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong! I’m here. How can I help you today?\\\",\\\"reasoning_content\\\":\\\"The user says \\\\\\\"ping\\\\\\\". Likely they want a simple ping response. They could be checking if the bot is alive. The best response: \\\\\\\"Pong!\\\\\\\" or somethin\",\"completion_status\":200,\"completion_type\":\"text/event-stream\",\"has_expected_model\":true,\"models\":[\"MiniMax-M2.5-highspeed\",\"MiniMax-M2.7-highspeed\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":1,"id":22,"mode":"partial","pack_id":1,"provider_id":3},"items":[{"account_status":"passed","batch_id":22,"id":22,"key_fingerprint":"sha256:3285f7116ec262d059c83dbdaf041eb7fcf6d04e2abdacbbff94891648beaccd","probe_summary_json":"{\"account_id\":\"37\",\"models\":[{\"id\":\"MiniMax-M2.7-highspeed\",\"display_name\":\"MiniMax-M2.7-highspeed\",\"type\":\"model\"},{\"id\":\"MiniMax-M2.5-highspeed\",\"display_name\":\"MiniMax-M2.5-highspeed\",\"type\":\"model\"}],\"probe_advisory\":false,\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"smoke_model_seen\":true,\"validation_status\":\"passed\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":28,"BatchID":22,"HostID":1,"ResourceType":"account","HostResourceID":"37","ResourceName":"minimax-01"}],"reconcile_count":0,"reconcile_runs":[]} diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/17-upstream-models.headers.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/17-upstream-models.headers.txt new file mode 100644 index 00000000..f372cfd5 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/17-upstream-models.headers.txt @@ -0,0 +1,16 @@ +HTTP/2 200 +server: nginx/1.24.0 (Ubuntu) +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: b057d215-8dc9-4dce-96bf-acd3e8e733a8 +age: 0 +content-length: 1282 +date: Thu, 21 May 2026 11:14:51 GMT +eo-log-uuid: 4174162878982047592 +eo-cache-status: MISS +cache-control: max-age=0 +nel: {"success_fraction":0.1,"report_to":"eo-nel","max_age":604800} +report-to: {"endpoints":[{"url":"https://nel.teo-rum.com/eo-cgi/nel"}],"group":"eo-nel","max_age":604800} + diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/18-upstream-models.body.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/18-upstream-models.body.json new file mode 100644 index 00000000..9fddc31f --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/18-upstream-models.body.json @@ -0,0 +1 @@ +{"data":[{"id":"Claude Opeus 4","type":"model","display_name":"Claude Opeus 4","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.5","type":"model","display_name":"MiniMax-M2.5","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.5-highspeed","type":"model","display_name":"MiniMax-M2.5-highspeed","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7","type":"model","display_name":"MiniMax-M2.7","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7-highspeed","type":"model","display_name":"MiniMax-M2.7-highspeed","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7-highspeed:MiniMax-M2.7-highspeed","type":"model","display_name":"MiniMax-M2.7-highspeed:MiniMax-M2.7-highspeed","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7-highspeedMiniMax-M2.7-highspeed","type":"model","display_name":"MiniMax-M2.7-highspeedMiniMax-M2.7-highspeed","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7:MiniMax-M2.7","type":"model","display_name":"MiniMax-M2.7:MiniMax-M2.7","created_at":"2024-01-01T00:00:00Z"},{"id":"MiniMax-M2.7MiniMax-M2.7","type":"model","display_name":"MiniMax-M2.7MiniMax-M2.7","created_at":"2024-01-01T00:00:00Z"},{"id":"claude-opus-4","type":"model","display_name":"claude-opus-4","created_at":"2024-01-01T00:00:00Z"}],"object":"list"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/19-upstream-chat.headers.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/19-upstream-chat.headers.txt new file mode 100644 index 00000000..d953f6a4 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/19-upstream-chat.headers.txt @@ -0,0 +1,16 @@ +HTTP/2 200 +server: nginx/1.24.0 (Ubuntu) +content-type: text/event-stream; charset=utf-8 +referrer-policy: strict-origin-when-cross-origin +x-content-type-options: nosniff +x-frame-options: DENY +x-request-id: ea3023b1-c8f4-4f5a-9095-f4978d64d5af +age: 0 +content-length: 509 +date: Thu, 21 May 2026 11:14:55 GMT +eo-log-uuid: 3222221884657771958 +eo-cache-status: MISS +cache-control: max-age=0 +nel: {"success_fraction":0.1,"report_to":"eo-nel","max_age":604800} +report-to: {"endpoints":[{"url":"https://nel.teo-rum.com/eo-cgi/nel"}],"group":"eo-nel","max_age":604800} + diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/20-upstream-chat.body.txt b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/20-upstream-chat.body.txt new file mode 100644 index 00000000..beb64948 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/20-upstream-chat.body.txt @@ -0,0 +1 @@ +{"id":"065e1c30f0582f615ee700689b575c88","object":"chat.completion","created":1779362099,"model":"MiniMax-M2.7-highspeed","choices":[{"index":0,"message":{"role":"assistant","content":"pong\n\nI'm here and ready to help! What can I do for you today?","reasoning_content":"The user said \"ping\". This is likely a simple test to see if I'm responsive. I should respond with \"pong\" or a friendly acknowledgment."},"finish_reason":"stop"}],"usage":{"prompt_tokens":42,"completion_tokens":51,"total_tokens":93}} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json new file mode 100644 index 00000000..eeb0082e --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json @@ -0,0 +1 @@ +{"artifact_dir": "/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import", "provider_id": "minimax", "batch_id": 22, "batch_status": "succeeded", "access_status_from_import": "subscription_ready", "provider_status_from_import": "active", "direct_models_http200": true, "direct_models_has_expected_model": true, "direct_models": ["MiniMax-M2.5-highspeed", "MiniMax-M2.7-highspeed"], "direct_chat_http200": true, "direct_chat_status": 200, "upstream_models": ["Claude Opeus 4", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.7-highspeed:MiniMax-M2.7-highspeed", "MiniMax-M2.7-highspeedMiniMax-M2.7-highspeed", "MiniMax-M2.7:MiniMax-M2.7", "MiniMax-M2.7MiniMax-M2.7", "claude-opus-4"], "upstream_models_has_expected_model": true, "upstream_chat_status": 200, "completion_classification": "unknown", "latest_access_status": "subscription_ready", "preview_available": true, "accepted_keys_count": 1, "subscription_group_id": "6", "import_group_id": "6"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/00-local-key-source.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/00-local-key-source.json new file mode 100644 index 00000000..e9f3a4ba --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/00-local-key-source.json @@ -0,0 +1,6 @@ +{ + "source": "file:/tmp/deepseek_2166.key", + "provider_id": "deepseek", + "upstream_key_prefix": "rk-aW9xpPDBP", + "upstream_key_suffix": "aUPv1B" +} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01-runtime-context.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01-runtime-context.json new file mode 100644 index 00000000..2dfe2a5d --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01-runtime-context.json @@ -0,0 +1,13 @@ +{ + "crm_base": "http://127.0.0.1:18100", + "host_base": "http://127.0.0.1:18097", + "crm_host_base": "http://127.0.0.1:18097", + "provider_id": "deepseek", + "subscription_user_id": "20", + "subscription_user_key_prefix": "sk-177936446", + "subscription_group_id": "5", + "admin_user_id": "1", + "managed_user_email": "20-d2c672db64e51303@sub2api.local", + "managed_user_id": "", + "managed_probe_key_prefix": "sk-relay-d2c672db6" +} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01a-create-host.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01a-create-host.json new file mode 100644 index 00000000..613eba80 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/01a-create-host.json @@ -0,0 +1 @@ +{"host_id": "remote43-fresh18097-deepseek-1779280533", "base_url": "http://127.0.0.1:18097", "host_version": "0.1.126", "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/20260521_201509_remote43_deepseek_key_import/02-import.headers.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/02-import.headers.txt new file mode 100644 index 00000000..35b44b44 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/02-import.headers.txt @@ -0,0 +1,5 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Date: Thu, 21 May 2026 12:15:28 GMT +Content-Length: 1023 + diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/03-import.body.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/03-import.body.json new file mode 100644 index 00000000..55610874 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/03-import.body.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"subscription_ready","accounts_count":1,"batch_id":24,"batch_status":"succeeded","channel":{"id":"4","name":"DeepSeek 默认渠道-subscription"},"gateway":{"ok":true,"status_code":200,"models":["deepseek-v4-flash","deepseek-v4-pro"],"has_expected_model":true,"completion_ok":true,"completion_status":200,"completion_content_type":"application/json","completion_body_preview":"{\"id\":\"chatcmpl-46834898-bb73-915e-a605-bc5a47e5db9a\",\"object\":\"chat.completion\",\"created\":1779365727,\"model\":\"deepseek-ai/DeepSeek-V4-Pro\",\"system_fingerprint\":\"\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"pong\",\"tool_calls\":null,\"reasoning_content\":\"We are asked: \\\"ping\\\". This is a very simple query. The user likely wants a response to \\\"ping\\\", which "},"group":{"id":"5","name":"DeepSeek 默认分组-subscription"},"plan":{"id":"4","name":"DeepSeek 默认套餐-subscription"},"provider_status":"active"} diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/04-batch-detail-initial.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/04-batch-detail-initial.json new file mode 100644 index 00000000..0b1bf7fe --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/04-batch-detail-initial.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":24,"BatchID":24,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-46834898-bb73-915e-a605-bc5a47e5db9a\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779365727,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"pong\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We are asked: \\\\\\\"ping\\\\\\\". This is a very simple query. The user likely wants a response to \\\\\\\"ping\\\\\\\", which \",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":1,"id":24,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"passed","batch_id":24,"id":24,"key_fingerprint":"sha256:6c228a62edd9c319e624cc10d761edaca1b96ac590aa196940a7ccb101ae6c92","probe_summary_json":"{\"account_id\":\"39\",\"models\":[{\"id\":\"deepseek-v4-pro\",\"display_name\":\"deepseek-v4-pro\",\"type\":\"model\"},{\"id\":\"deepseek-v4-flash\",\"display_name\":\"deepseek-v4-flash\",\"type\":\"model\"}],\"probe_advisory\":false,\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"smoke_model_seen\":true,\"validation_status\":\"passed\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":30,"BatchID":24,"HostID":1,"ResourceType":"account","HostResourceID":"39","ResourceName":"deepseek-01"}],"reconcile_count":0,"reconcile_runs":[]} diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/05-subscription-access-prep.sql b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/05-subscription-access-prep.sql new file mode 100644 index 00000000..8ffe59e6 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/05-subscription-access-prep.sql @@ -0,0 +1,46 @@ +BEGIN; +UPDATE users +SET balance = CASE WHEN balance < 10 THEN 10 ELSE balance END, + updated_at = now() +WHERE id = 20; +UPDATE api_keys +SET group_id = 5, + updated_at = now() +WHERE key = 'sk-1779364460-a9bac8bd'; +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 ( + 20, + 5, + now(), + now() + interval '30 days', + 'active', + 1, + now(), + 'hermes remote subscription 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; \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/06-subscription-access-prep.psql.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/06-subscription-access-prep.psql.txt new file mode 100644 index 00000000..75b5e3a1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/06-subscription-access-prep.psql.txt @@ -0,0 +1,3 @@ +BEGIN +UPDATE 1 +ROLLBACK diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/07-redis-targeted-invalidation.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/07-redis-targeted-invalidation.txt new file mode 100644 index 00000000..76b46f61 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/07-redis-targeted-invalidation.txt @@ -0,0 +1,4 @@ +auth_cache_key=apikey:auth:ca052e60820f3457831e3080de38cc3c14889bef4838b485377afca5567a1481 +balance_cache_key=billing:balance:20 +subscription_cache_key=billing:sub:20:5 +0 diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/08-subscription-group-state.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/08-subscription-group-state.json new file mode 100644 index 00000000..fe76e94b --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/08-subscription-group-state.json @@ -0,0 +1 @@ +{"group_id" : 5, "group" : null, "subscription" : null, "key" : {"id":19,"user_id":20,"key":"sk-1779364460-a9bac8bd","name":"relay-sub-1779364460-a9bac8bd-key","group_id":null,"status":"active","created_at":"2026-05-21T11:54:21.758954+00:00","updated_at":"2026-05-21T11:54:21.758954+00:00","deleted_at":null,"ip_whitelist":null,"ip_blacklist":null,"quota":0.00000000,"quota_used":0.00000000,"expires_at":null,"last_used_at":null,"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/20260521_201509_remote43_deepseek_key_import/09-models.headers.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/09-models.headers.txt new file mode 100644 index 00000000..db727c86 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/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: 244f86c2-e121-476c-a482-45cc3f86e3f5 +Date: Thu, 21 May 2026 12:15:36 GMT +Content-Length: 248 + diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/10-models.body.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/10-models.body.json new file mode 100644 index 00000000..eb1aa93c --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/10-models.body.json @@ -0,0 +1 @@ +{"data":[{"id":"deepseek-v4-flash","type":"model","display_name":"deepseek-v4-flash","created_at":"2024-01-01T00:00:00Z"},{"id":"deepseek-v4-pro","type":"model","display_name":"deepseek-v4-pro","created_at":"2024-01-01T00:00:00Z"}],"object":"list"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/11-chat.headers.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/11-chat.headers.txt new file mode 100644 index 00000000..3fb9495d --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/11-chat.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +Content-Type: application/json +Date: Thu, 21 May 2026 12:15:39 GMT +Referrer-Policy: strict-origin-when-cross-origin +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-Request-Id: db98fb33-aaf7-45b3-b33f-5269232ff11f +Content-Length: 527 + diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/12-chat.body.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/12-chat.body.json new file mode 100644 index 00000000..e63dafcb --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/12-chat.body.json @@ -0,0 +1 @@ +{"id":"chatcmpl-1f30ee00-c146-9555-a47b-e807a56468ca","object":"chat.completion","created":1779365742,"model":"deepseek-ai/DeepSeek-V4-Pro","system_fingerprint":"","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"pong","tool_calls":null,"reasoning_content":"We are asked: \"ping\". The user likely just wants a simple \"pong\" response, or maybe they're testing if the assistant is alive. I'll respond with \"pong\"."}}],"usage":{"prompt_tokens":5,"completion_tokens":43,"total_tokens":48}} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/13-provider-status.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/13-provider-status.json new file mode 100644 index 00000000..adb594f1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/13-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","id":24,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18097","host_id":"remote43-fresh18097-deepseek-1779280533","host_version":"0.1.126"},"latest_access_status":"subscription_ready","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":1,"pack":{"pack_id":"openai-cn-pack","version":"1.0.1"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"active","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/14-access-status.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/14-access-status.json new file mode 100644 index 00000000..f0be0dc0 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/14-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"subscription_ready","batch_id":24,"closures_count":1,"latest_access_status":"subscription_ready","latest_closure":{"closure_type":"subscription","details_json":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-46834898-bb73-915e-a605-bc5a47e5db9a\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779365727,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"pong\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We are asked: \\\\\\\"ping\\\\\\\". This is a very simple query. The user likely wants a response to \\\\\\\"ping\\\\\\\", which \",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"status_code\":200}","id":24,"status":"subscription_ready"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/15-access-preview.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/15-access-preview.json new file mode 100644 index 00000000..59bbb5df --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/15-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"subscription","available":true,"message":"latest access status: subscription_ready"} diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/16-batch-detail-final.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/16-batch-detail-final.json new file mode 100644 index 00000000..0b1bf7fe --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/16-batch-detail-final.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":24,"BatchID":24,"ClosureType":"subscription","Status":"subscription_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-46834898-bb73-915e-a605-bc5a47e5db9a\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779365727,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"pong\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We are asked: \\\\\\\"ping\\\\\\\". This is a very simple query. The user likely wants a response to \\\\\\\"ping\\\\\\\", which \",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"subscription_ready","batch_status":"succeeded","host_id":1,"id":24,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"passed","batch_id":24,"id":24,"key_fingerprint":"sha256:6c228a62edd9c319e624cc10d761edaca1b96ac590aa196940a7ccb101ae6c92","probe_summary_json":"{\"account_id\":\"39\",\"models\":[{\"id\":\"deepseek-v4-pro\",\"display_name\":\"deepseek-v4-pro\",\"type\":\"model\"},{\"id\":\"deepseek-v4-flash\",\"display_name\":\"deepseek-v4-flash\",\"type\":\"model\"}],\"probe_advisory\":false,\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"smoke_model_seen\":true,\"validation_status\":\"passed\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":30,"BatchID":24,"HostID":1,"ResourceType":"account","HostResourceID":"39","ResourceName":"deepseek-01"}],"reconcile_count":0,"reconcile_runs":[]} diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/17-upstream-models.headers.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/17-upstream-models.headers.txt new file mode 100644 index 00000000..6e78dfc9 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/17-upstream-models.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +date: Thu, 21 May 2026 12:15:47 GMT +server: uvicorn +content-length: 891 +content-type: application/json +x-content-type-options: nosniff +referrer-policy: no-referrer +x-frame-options: DENY + diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/18-upstream-models.body.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/18-upstream-models.body.json new file mode 100644 index 00000000..18d4cbad --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/18-upstream-models.body.json @@ -0,0 +1 @@ +{"object":"list","data":[{"id":"deepseek-ai/DeepSeek-V4-Flash","object":"model","created":1777039693,"owned_by":"gongxiang"},{"id":"deepseek-ai/DeepSeek-V4-Flash-Max","object":"model","created":1777039693,"owned_by":"gongxiang"},{"id":"deepseek-ai/DeepSeek-V4-Pro","object":"model","created":1777039670,"owned_by":"gongxiang"},{"id":"deepseek-ai/DeepSeek-V4-Pro-Max","object":"model","created":1777039670,"owned_by":"gongxiang"},{"id":"ZhipuAI/GLM-5.1","object":"model","created":1776264446,"owned_by":"gongxiang"},{"id":"inclusionAI/Ling-2.6-1T","object":"model","created":1779365748,"owned_by":"gongxiang"},{"id":"inclusionAI/Ring-2.6-1T","object":"model","created":1779365748,"owned_by":"gongxiang"},{"id":"moonshotai/Kimi-K2.5","object":"model","created":1770027165,"owned_by":"gongxiang"},{"id":"stepfun-ai/Step-3.5-Flash","object":"model","created":1770216621,"owned_by":"gongxiang"}]} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/19-upstream-chat.headers.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/19-upstream-chat.headers.txt new file mode 100644 index 00000000..ad90ea02 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/19-upstream-chat.headers.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +date: Thu, 21 May 2026 12:15:52 GMT +server: uvicorn +content-length: 581 +content-type: application/json +x-content-type-options: nosniff +referrer-policy: no-referrer +x-frame-options: DENY + diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/20-upstream-chat.body.txt b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/20-upstream-chat.body.txt new file mode 100644 index 00000000..64dcde50 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/20-upstream-chat.body.txt @@ -0,0 +1 @@ +{"id":"chatcmpl-514b677a-8bd7-98e1-ba86-e307fe2737da","object":"chat.completion","created":1779365755,"model":"deepseek-ai/DeepSeek-V4-Pro","system_fingerprint":"","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"pong","tool_calls":null,"reasoning_content":"We are asked: \"ping\". This is a simple interaction. The user said \"ping\". The assistant should respond with \"pong\" or something similar. The instruction is to be helpful. So I'll just respond with \"pong\"."}}],"usage":{"prompt_tokens":5,"completion_tokens":53,"total_tokens":58}} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json new file mode 100644 index 00000000..071ba764 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json @@ -0,0 +1 @@ +{"artifact_dir": "/home/long/project/sub2api-cn-relay-manager/artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import", "provider_id": "deepseek", "batch_id": 24, "batch_status": "succeeded", "access_status_from_import": "subscription_ready", "provider_status_from_import": "active", "direct_models_http200": true, "direct_models_has_expected_model": true, "direct_models": ["deepseek-v4-flash", "deepseek-v4-pro"], "direct_chat_http200": true, "direct_chat_status": 200, "upstream_models": ["deepseek-ai/DeepSeek-V4-Flash", "deepseek-ai/DeepSeek-V4-Flash-Max", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Pro-Max", "ZhipuAI/GLM-5.1", "inclusionAI/Ling-2.6-1T", "inclusionAI/Ring-2.6-1T", "moonshotai/Kimi-K2.5", "stepfun-ai/Step-3.5-Flash"], "upstream_models_has_expected_model": false, "upstream_chat_status": 200, "completion_classification": "unknown", "latest_access_status": "subscription_ready", "preview_available": true, "accepted_keys_count": 1, "subscription_group_id": "5", "import_group_id": "5"} \ No newline at end of file diff --git a/artifacts/real-host-acceptance/20260521_210403/00-artifact-guide.txt b/artifacts/real-host-acceptance/20260521_210403/00-artifact-guide.txt new file mode 100644 index 00000000..35273ff1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/00-artifact-guide.txt @@ -0,0 +1,30 @@ +真实宿主验收产物 -> 速查清单对应 + +清单 1(环境 / host 前置) +- 01-create-host.json +- 02-probe-host.json + +清单 2(channel 宿主契约 / 导入落库) +- 03-install-pack.json +- 04-preview-import.json +- 05-import.json +- 05a-batch-detail-pre-access.json(若拿到 batch_id 且非 dry-run) +- 08-provider-status.json +- 09-reconcile.json +- 10-batch-detail.json(若拿到 batch_id 且非 dry-run) + +清单 3(access / key 闭环状态) +- 06-access-preview.json +- 07-access-status.json + +清单 4(必须分层留证据,不可混用) +- account 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 GET /api/v1/admin/accounts/:id/models +- 普通用户 / managed key 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 GET /v1/models +- completion 视角:由 AFTER_IMPORT_HOOK_COMMAND 额外落证据,例如 POST /v1/chat/completions + +红线: +- /api/v1/admin/accounts/:id/models 正确 ≠ /v1/models 正确 +- /v1/models 正确 ≠ /v1/chat/completions 正确 +- admin API 成功 ≠ 普通用户链路成功 + +当前 hook 配置:disabled diff --git a/artifacts/real-host-acceptance/20260521_210403/01-create-host.json b/artifacts/real-host-acceptance/20260521_210403/01-create-host.json new file mode 100644 index 00000000..56fce13d --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/01-create-host.json @@ -0,0 +1 @@ +{"host_id":"remote43-fresh18097-deepseek-1779280533","base_url":"http://127.0.0.1:18097","host_version":"0.1.126","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/20260521_210403/02-probe-host.json b/artifacts/real-host-acceptance/20260521_210403/02-probe-host.json new file mode 100644 index 00000000..003d7a2d --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/02-probe-host.json @@ -0,0 +1 @@ +{"host_id":"remote43-fresh18097-deepseek-1779280533","base_url":"http://127.0.0.1:18097","host_version":"0.1.126","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/20260521_210403/03-install-pack.json b/artifacts/real-host-acceptance/20260521_210403/03-install-pack.json new file mode 100644 index 00000000..6a9dbdbb --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/03-install-pack.json @@ -0,0 +1 @@ +{"already_installed":true,"host_version":"0.1.126","pack_id":"openai-cn-pack","providers":[{"display_name":"DeepSeek OpenAI Compatible","provider_id":"deepseek"},{"display_name":"MiniMax OpenAI Compatible","provider_id":"minimax"},{"display_name":"OpenAI 中转兼容","provider_id":"openai-zhongzhuan"}],"version":"1.0.1"} diff --git a/artifacts/real-host-acceptance/20260521_210403/04-preview-import.json b/artifacts/real-host-acceptance/20260521_210403/04-preview-import.json new file mode 100644 index 00000000..688c857d --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/04-preview-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"decisions":{"channel":{"Action":"reuse","Suggested":"DeepSeek 默认渠道","ExistingID":"2","Reason":"matching managed resource already exists"},"group":{"Action":"reuse","Suggested":"DeepSeek 默认分组","ExistingID":"3","Reason":"matching managed resource already exists"},"plan":{"Action":"reuse","Suggested":"DeepSeek 默认套餐","ExistingID":"2","Reason":"matching managed resource already exists"}},"names":{"Group":"DeepSeek 默认分组","Channel":"DeepSeek 默认渠道","Plan":"DeepSeek 默认套餐"}} diff --git a/artifacts/real-host-acceptance/20260521_210403/05-import.json b/artifacts/real-host-acceptance/20260521_210403/05-import.json new file mode 100644 index 00000000..a0e238ec --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/05-import.json @@ -0,0 +1 @@ +{"accepted_keys_count":1,"access_status":"self_service_ready","accounts_count":1,"batch_id":29,"batch_status":"succeeded","channel":{"id":"8","name":"DeepSeek 默认渠道-self-service"},"gateway":{"ok":true,"status_code":200,"models":["deepseek-v4-flash","deepseek-v4-pro"],"has_expected_model":true,"completion_ok":true,"completion_status":200,"completion_content_type":"application/json","completion_body_preview":"{\"id\":\"chatcmpl-790054f9-3ed4-9f97-8928-4366ec706642\",\"object\":\"chat.completion\",\"created\":1779368660,\"model\":\"deepseek-ai/DeepSeek-V4-Pro\",\"system_fingerprint\":\"\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"Pong!\",\"tool_calls\":null,\"reasoning_content\":\"We need to respond to \\\"ping\\\". The user is saying \\\"ping\\\", which is a common test message. The assista"},"group":{"id":"9","name":"DeepSeek 默认分组-self-service"},"plan":null,"provider_status":"active"} diff --git a/artifacts/real-host-acceptance/20260521_210403/05a-batch-detail-pre-access.json b/artifacts/real-host-acceptance/20260521_210403/05a-batch-detail-pre-access.json new file mode 100644 index 00000000..832cecac --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/05a-batch-detail-pre-access.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":33,"BatchID":29,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-790054f9-3ed4-9f97-8928-4366ec706642\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779368660,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong!\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We need to respond to \\\\\\\"ping\\\\\\\". The user is saying \\\\\\\"ping\\\\\\\", which is a common test message. The assista\",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"status_code\":200}"}],"access_count":1,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","host_id":1,"id":29,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"passed","batch_id":29,"id":29,"key_fingerprint":"sha256:6c228a62edd9c319e624cc10d761edaca1b96ac590aa196940a7ccb101ae6c92","probe_summary_json":"{\"account_id\":\"44\",\"models\":[{\"id\":\"deepseek-v4-pro\",\"display_name\":\"deepseek-v4-pro\",\"type\":\"model\"},{\"id\":\"deepseek-v4-flash\",\"display_name\":\"deepseek-v4-flash\",\"type\":\"model\"}],\"probe_advisory\":false,\"probe_message\":\"\",\"probe_ok\":true,\"probe_status\":\"passed\",\"smoke_model_seen\":true,\"validation_status\":\"passed\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":41,"BatchID":29,"HostID":1,"ResourceType":"account","HostResourceID":"44","ResourceName":"deepseek-01"}],"reconcile_count":0,"reconcile_runs":[]} diff --git a/artifacts/real-host-acceptance/20260521_210403/06-access-preview.json b/artifacts/real-host-acceptance/20260521_210403/06-access-preview.json new file mode 100644 index 00000000..a4fab7a1 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/06-access-preview.json @@ -0,0 +1 @@ +{"provider_id":"deepseek","mode":"self_service","available":true,"message":"latest access status: fully_ready"} diff --git a/artifacts/real-host-acceptance/20260521_210403/07-access-status.json b/artifacts/real-host-acceptance/20260521_210403/07-access-status.json new file mode 100644 index 00000000..082bd9bd --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/07-access-status.json @@ -0,0 +1 @@ +{"batch_access_status":"self_service_ready","batch_id":29,"closures_count":1,"latest_access_status":"fully_ready","latest_closure":{"closure_type":"self_service","details_json":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-790054f9-3ed4-9f97-8928-4366ec706642\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779368660,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong!\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We need to respond to \\\\\\\"ping\\\\\\\". The user is saying \\\\\\\"ping\\\\\\\", which is a common test message. The assista\",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"status_code\":200}","id":33,"status":"self_service_ready"},"pack_id":"openai-cn-pack","provider_id":"deepseek"} diff --git a/artifacts/real-host-acceptance/20260521_210403/08-provider-status.json b/artifacts/real-host-acceptance/20260521_210403/08-provider-status.json new file mode 100644 index 00000000..845c3a31 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/08-provider-status.json @@ -0,0 +1 @@ +{"access_closures_count":1,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","id":29,"mode":"partial"},"host":{"base_url":"http://127.0.0.1:18097","host_id":"remote43-fresh18097-deepseek-1779280533","host_version":"0.1.126"},"latest_access_status":"fully_ready","latest_reconcile_status":"not_run","latest_reconcile_summary":{},"managed_resources_count":1,"pack":{"pack_id":"openai-cn-pack","version":"1.0.1"},"provider":{"display_name":"DeepSeek OpenAI Compatible","platform":"openai","provider_id":"deepseek"},"provider_status":"active","reconcile_runs_count":0} diff --git a/artifacts/real-host-acceptance/20260521_210403/09-reconcile.json b/artifacts/real-host-acceptance/20260521_210403/09-reconcile.json new file mode 100644 index 00000000..b0aed722 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/09-reconcile.json @@ -0,0 +1 @@ +{"batch_id":29,"extra_count":3,"missing_count":6,"provider_id":"deepseek","stale_noise_count":0,"status":"drifted","summary":{"access_rechecked":true,"access_status":"self_service_ready","extra_count":3,"host_version":"0.1.126","missing_count":6,"probe_failures":0,"raw_extra_count":3,"stale_noise_accounts":[],"stale_noise_count":0}} diff --git a/artifacts/real-host-acceptance/20260521_210403/10-batch-detail.json b/artifacts/real-host-acceptance/20260521_210403/10-batch-detail.json new file mode 100644 index 00000000..3d9ca951 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/10-batch-detail.json @@ -0,0 +1 @@ +{"access_closures":[{"ID":33,"BatchID":29,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-790054f9-3ed4-9f97-8928-4366ec706642\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779368660,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"Pong!\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We need to respond to \\\\\\\"ping\\\\\\\". The user is saying \\\\\\\"ping\\\\\\\", which is a common test message. The assista\",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"status_code\":200}"},{"ID":34,"BatchID":29,"ClosureType":"self_service","Status":"self_service_ready","DetailsJSON":"{\"completion_ok\":true,\"completion_preview\":\"{\\\"id\\\":\\\"chatcmpl-591428f5-c508-9e55-bfe7-c6dbb89b5059\\\",\\\"object\\\":\\\"chat.completion\\\",\\\"created\\\":1779368666,\\\"model\\\":\\\"deepseek-ai/DeepSeek-V4-Pro\\\",\\\"system_fingerprint\\\":\\\"\\\",\\\"choices\\\":[{\\\"index\\\":0,\\\"finish_reason\\\":\\\"stop\\\",\\\"message\\\":{\\\"role\\\":\\\"assistant\\\",\\\"content\\\":\\\"pong\\\",\\\"tool_calls\\\":null,\\\"reasoning_content\\\":\\\"We are asked: \\\\\\\"ping\\\\\\\". The user literally wrote \\\\\\\"ping\\\\\\\". This is likely a simple test to see if the AI \",\"completion_status\":200,\"completion_type\":\"application/json\",\"has_expected_model\":true,\"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\"],\"ok\":true,\"reconcile_rerun\":true,\"status_code\":200}"}],"access_count":2,"batch":{"access_status":"self_service_ready","batch_status":"succeeded","host_id":1,"id":29,"mode":"partial","pack_id":1,"provider_id":1},"items":[{"account_status":"warning","batch_id":29,"id":29,"key_fingerprint":"sha256:6c228a62edd9c319e624cc10d761edaca1b96ac590aa196940a7ccb101ae6c92","probe_summary_json":"{\"account_id\":\"44\",\"models\":[{\"id\":\"deepseek-v4-pro\",\"display_name\":\"deepseek-v4-pro\",\"type\":\"model\"},{\"id\":\"deepseek-v4-flash\",\"display_name\":\"deepseek-v4-flash\",\"type\":\"model\"}],\"probe_advisory\":true,\"probe_message\":\"账号已被探测为不支持 OpenAI Responses API(如 DeepSeek/Kimi 等三方兼容上游),账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。\",\"probe_ok\":false,\"probe_status\":\"failed\",\"reconcile_rerun\":true,\"smoke_model_seen\":true,\"validation_status\":\"warning\"}"}],"items_count":1,"managed_count":1,"managed_resources":[{"ID":41,"BatchID":29,"HostID":1,"ResourceType":"account","HostResourceID":"44","ResourceName":"deepseek-01"}],"reconcile_count":1,"reconcile_runs":[{"ID":9,"BatchID":29,"HostID":1,"ProviderID":1,"Status":"drifted","SummaryJSON":"{\"access_rechecked\":true,\"access_status\":\"self_service_ready\",\"extra_count\":3,\"host_version\":\"0.1.126\",\"missing_count\":6,\"probe_failures\":0,\"raw_extra_count\":3,\"stale_noise_accounts\":[],\"stale_noise_count\":0}"}]} diff --git a/artifacts/real-host-acceptance/20260521_210403/11-rollback.json b/artifacts/real-host-acceptance/20260521_210403/11-rollback.json new file mode 100644 index 00000000..6e8e4056 --- /dev/null +++ b/artifacts/real-host-acceptance/20260521_210403/11-rollback.json @@ -0,0 +1 @@ +{"batch_id":29,"deleted_accounts":1,"deleted_channels":0,"deleted_groups":0,"deleted_plans":0} diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index 014ec9ba..49cee004 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -1,14 +1,14 @@ # sub2api-cn-relay-manager 执行板 -日期:2026-05-20 -当前 Gate:BLOCKED(代码门禁已通过,且 `scripts/import_remote43_provider.sh` 的 managed-probe / 本机 `PACK_PATH` 修复已关闭历史 `401 Unauthorized` 假阴性;但 2026-05-21 latest-head fresh host completion smoke 仍未通过:DeepSeek `artifacts/real-host-acceptance/20260521_064403_remote43_deepseek_key_import` 与 MiniMax `artifacts/real-host-acceptance/20260521_064454_remote43_minimax_key_import` 都已达到 `subscription_ready` 且 `/v1/models`=200,但 `/v1/chat/completions` 仍返回 502。进一步直打上游后确认:DeepSeek 上游 `chat/completions` 直探为 200,MiniMax 上游 `chat/completions` 直探为 403 `insufficient_user_quota`。因此当前不允许宣称“完全验收/APPROVED”) +日期:2026-05-21 +当前 Gate:APPROVED(代码门禁已通过,并且 2026-05-21 已继续收掉 account probe、gateway probe 认证语义和 latest-head `self_service` fresh-host 复验的剩余问题。最新 MiniMax 53hk fresh-host 验收 `artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json`、DeepSeek 2166 `subscription` fresh-host 验收 `artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json`、以及 latest-head `self_service` 标准 fresh-host 验收 `artifacts/real-host-acceptance/20260521_210403/05-import.json` / `07-access-status.json` 已共同证明:`subscription` 与 `self_service` 主链路都能在真实 fresh host 上闭环到 ready,host `/v1/models` 与 `/v1/chat/completions` 也都真实返回 `HTTP 200`。当前仍存在的 `reconcile=drifted` 只反映共享 fresh-host 环境里的历史残留资源,不阻塞 PRD 首版放行) 目标:实现独立控制面、零侵入宿主、可导入国产模型并具备可运维的导入/回滚/访问闭环。 ## 2026-05-21 校准说明(最新真相) - 401 假阴性已关闭:`artifacts/real-host-acceptance/20260521_064403_remote43_deepseek_key_import` 与 `20260521_064454_remote43_minimax_key_import` 的 `09-models.headers.txt` 都是 `HTTP 200`,说明 managed probe key / 本机 `PACK_PATH` 修复生效。 - fresh-host DB 侧状态也已对齐:在脚本指向正确的 `sub2api-fresh-deepseek-20260519_115244-{postgres,redis}-1` 后,`08-subscription-group-state.json` 已能看到真实的 managed user / subscription / key 绑定,而不是旧 relaymgr 容器造成的空/null 假象。 -- 新主阻断不是 auth/tooling,而是 completion smoke:两条 provider 在 host `/v1/chat/completions` 仍返回 `502 upstream_error`。 +- 这一轮之前的新主阻断不是 auth/tooling,而是 completion smoke:两条 provider 一度在 host `/v1/chat/completions` 返回 `502 upstream_error`。 - 上游直探分流证明: - DeepSeek 上游 `/chat/completions` = `HTTP 200`,因此 host 侧 502 是真实兼容性问题,不是单纯 key 失效。 - MiniMax 上游 `/chat/completions` = `HTTP 403 insufficient_user_quota`,因此当前验证 key 本身不具备真实 completion 流量能力。 @@ -19,8 +19,32 @@ - 汇总证据:`artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md` - 2026-05-21 当前代码已补一层稳定性收口:`internal/provision/import_service.go` 在 replacement account 全部通过 smoke 校验后,会先清理同 provider 前缀的旧 account 再做 gateway closure,避免重复导入把同 group 污染成多活重复 account;若 replacement 校验失败则保留旧 account,不做破坏性清理。 - 2026-05-21 同时新增 `scripts/check_deepseek_completion_split.sh`,可把 DeepSeek “host models=200 / host chat=502 / upstream chat=200” 压缩成可复现的最小 issue 证据包。 +- 2026-05-21 本机 CRM(18100) 已补上 reconcile stale-noise 分类:对 `remote43-fresh18097-deepseek-1779280533` 执行 `POST /api/providers/deepseek/reconcile` 后,`batch_id=13` 返回 `extra_count=0`、`raw_extra_count=15`、`stale_noise_count=15`、`status=degraded`;说明旧同前缀账号已不再被记成 drift,只剩真实 `probe_failures=1` 继续暴露。 +- 2026-05-21 当前代码又补了一层 readiness 收口:`internal/access/closure.go`、`internal/provision/import_service.go`、`internal/provision/batch_detail_and_reconcile_service.go`、`internal/app/http_api.go` 现在都会在 `/v1/models` 通过后继续执行一次最小 `POST /v1/chat/completions` smoke;未通过 completion 的链路不再被记成 `subscription_ready/self_service_ready`。 +- 2026-05-21 `scripts/import_remote43_provider.sh` 已新增 upstream `/models` 与 `/chat/completions` 直探,额外落盘 `17-upstream-models.*`、`19-upstream-chat.*`、`21-summary.json`,用于把“宿主兼容层问题”和“上游 key/quota 问题”分流。 +- 2026-05-21 patched CRM fresh-host 复验结果: + - DeepSeek:`artifacts/real-host-acceptance/20260521_172709_remote43_deepseek_key_import/03-import.body.json` 已返回 `access_status=broken`,并在 `gateway` 中明确写入 `completion_ok=false`、`completion_status=502`;`21-summary.json` 显示 `upstream_chat_status=403`、`completion_classification=upstream_key_quota_issue` + - MiniMax:`artifacts/real-host-acceptance/20260521_172646_remote43_minimax_key_import/03-import.body.json` 已返回 `access_status=broken`,并在 `gateway` 中明确写入 `completion_ok=false`、`completion_status=502`;`21-summary.json` 同样显示 `upstream_chat_status=403`、`completion_classification=upstream_key_quota_issue` +- 2026-05-21 account probe 最后一层剩余问题已确认并关闭: + - 新 artifact:`artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import` + - 结论:MiniMax 53hk 当前已从 `partially_succeeded/degraded` 收口到 `succeeded/active` + - 根因 1:`internal/host/sub2api/accounts.go` 之前只取 SSE 最后一个 payload 的 `status/message/ok/success`,会把 `type=error` 的真实错误原因吃掉,落成“failed + 空 message” + - 根因 2:`internal/provision/import_service.go` 与 reconcile rerun 之前没有给 `POST /api/v1/admin/accounts/:id/test` 传 `provider.SmokeTestModel`,宿主默认用 `gpt-5.4` 做账号测试,因 channel pricing restriction 误把 MiniMax 账号记成 failed + - 修复后最新 batch detail `04-batch-detail-initial.json` 已显示:`account_status=passed`、`probe_ok=true`、`validation_status=passed` +- 2026-05-21 DeepSeek 2166 路由的项目级 fresh-host 复验也已收口: + - 新 artifact:`artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import` + - 结论:DeepSeek 当前已从 `partially_succeeded/degraded` 收口到 `succeeded/active` + - 关键证据:`21-summary.json` 显示 `batch_status=succeeded`、`provider_status_from_import=active`、host `/v1/chat/completions` = `200`、upstream `/chat/completions` = `200` + - `16-batch-detail-final.json` 已显示:`account_status=passed`、`probe_ok=true`、`validation_status=passed` +- 2026-05-21 latest-head `self_service` fresh-host 复验也已收口: + - 最新完整标准 artifact:`artifacts/real-host-acceptance/20260521_210403` + - `05-import.json` 已显示:`batch_status=succeeded`、`access_status=self_service_ready`、`provider_status=active` + - `06-access-preview.json` / `07-access-status.json` 已显示:`available=true`、`latest_access_status=fully_ready` + - 期间暴露并修复的最后一个代码级根因是:`internal/host/sub2api/gateway_probe.go` 之前把普通用户 gateway key 错误地放进 `x-api-key`,而真实宿主要求 `Authorization: Bearer ` + - `09-reconcile.json` 仍是 `status=drifted`,但 `summary.access_status=self_service_ready`,说明当前 shared fresh-host 里的历史残留资源仍存在,不影响本次访问闭环结论 +- 结论更新:旧的 code-side false positive、MiniMax account probe 假失败、DeepSeek completion 阻断、以及 latest-head `self_service` gateway probe 认证偏差都已真实关闭;当前 Gate 可以按 PRD 首版范围提升为 `APPROVED`。 - 调通细节与经验沉淀:`docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md` -- 代码门禁与本地运行态已于 2026-05-21 再次独立复跑:`gofmt -l .`、`go vet ./...`、`go test ./... -count=1`、`go test -race ./... -count=1`、`go test -cover ./internal/... -count=1`、`go test ./tests/integration/... -count=1` 全通过;本机 CRM(18100) 的 `GET /healthz` / 带 token 的 `GET /api/hosts` = `200`,另起 fresh smoke 实例 `127.0.0.1:18101` 也成功返回 `GET /healthz = ok`、`GET /api/hosts = {"hosts":[]}`。 +- 代码门禁与本地运行态已于 2026-05-21 在这轮补丁后再次独立复跑:`gofmt -l .`、`go vet ./...`、`go test ./... -count=1`、`go test -race ./... -count=1`、`go test -cover ./internal/... -count=1`、`go test ./tests/integration/... -count=1`、`bash ./scripts/test_real_host_scripts.sh` 全通过;覆盖率当前为 `internal/access 80.5%`、`internal/host/sub2api 78.1%`、`internal/pack 73.9%`、`internal/provision 76.3%`。 ## 本轮已完成 @@ -59,6 +83,17 @@ - subscription access 改为宿主侧闭环:CRM 不再依赖外部预先给定的宿主普通用户 key,而是按 `subscription_users` selector 在宿主创建/查找托管普通用户、登录创建托管 key、回写 allowed_groups / balance、再执行订阅分配 - account 创建请求现在同步写入 `credentials.model_mapping`,修正 `/v1/models` 读取 account model whitelist 时回退到 GPT 默认集合的问题 - 新增/更新测试覆盖:`internal/access`、`internal/provision`、`internal/host/sub2api` +11. current-code access ready 语义已提升到 completion 层 + - `/v1/models` 不再单独决定 `subscription_ready/self_service_ready` + - 只有 `/v1/models` 命中 `smoke_test_model` 且 `/v1/chat/completions` smoke 成功,控制面才会把 access 状态记成 ready + - access closure / import runtime artifact / reconcile rerun payload 都会持久化 `completion_ok/completion_status/completion_type/completion_preview` +12. current-code remote43 验收脚本已补 upstream API 证据层 + - `scripts/import_remote43_provider.sh` 会直探 provider `base_url` 对应的 upstream `/models` 与 `/chat/completions` + - 新增 `21-summary.json`,用于把 completion 失败自动分流成 `host_compatibility_gap` 或 `upstream_key_quota_issue` +13. patched CRM external validation 已完成 + - patched CRM 实例下,DeepSeek 与 MiniMax 都已验证“completion smoke 通过时能落成 succeeded/active,失败时不会误记成 ready” + - `20260521_191418_remote43_minimax_key_import` 与 `20260521_201509_remote43_deepseek_key_import` 已同时证明当前 `subscription` provider 链路可真实闭环 + - `20260521_210403` 已证明 latest-head `self_service` 标准 fresh-host 验收也可闭环到 `self_service_ready` / `fully_ready` ## 已验证门禁 @@ -67,10 +102,11 @@ - `go test ./...` ✅ - `go test -race ./...` ✅ - `go test -cover ./internal/...` ✅ - - `internal/access`: `77.3%` - - `internal/pack`: `72.7%` - - `internal/provision`: `74.6%` - - `internal/store/sqlite`: `61.3%` + - `internal/access`: `80.5%` + - `internal/host/sub2api`: `78.1%` + - `internal/pack`: `73.9%` + - `internal/provision`: `76.3%` + - `internal/store/sqlite`: `61.4%` - `go test ./tests/integration/... -count=1` ✅ - `bash ./scripts/test_real_host_scripts.sh` ✅ @@ -121,14 +157,14 @@ ## 剩余项(含当前外部门禁) -1. current-code real-host 主阻断已关闭,剩余为验收脚本噪音 +1. current-code code-side false positive 已关闭,fresh-host 外网复验证据已补齐 - `artifacts/real-host-acceptance/20260520_222713_crm18100_live_model_mapping_validation` 已证明 account `credentials.model_mapping` 与 managed key 视角模型暴露正确 - - `20260521_011544_remote43_minimax_key_import` / `20260521_011717_remote43_deepseek_key_import` 又进一步证明在 latest-head CRM 上,fresh import 后两条 provider 都进入 `subscription_ready` + - `20260521_172646_remote43_minimax_key_import` / `20260521_172709_remote43_deepseek_key_import` 已证明 patched CRM 下 control plane 会把 completion 失败正确落成 `broken` - 旧阻断“CRM(18100) 进程过旧导致 MiniMax channel `model_pricing=[]`”已被 host admin 现场证据推翻:`GET /api/v1/admin/channels/5` 现已返回非空 `model_pricing` - - 当前 remaining gap 收敛为 `scripts/import_remote43_provider.sh` 的 direct host probe:artifact 中 `09-models.headers.txt` / `11-chat.headers.txt` 仍可见 `401 Unauthorized`,但这与同批次 CRM 记录的 `gateway.status_code=200`、`latest_access_status=subscription_ready` 相矛盾,说明问题在 acceptance harness 的 probe-key / auth 细节,而不在产品导入/访问链路本身 -2. 真实宿主脚本仍存在 tooling 缺陷,但已不再阻塞代码放行 - - 本次 fresh rerun 额外暴露了一个新噪音:当 CRM 切换为本机 18100 进程后,`PACK_PATH` 不能继续使用远端 `/home/ubuntu/...`;若未显式改成控制面本机可读路径,会在 import 早期报 `stat pack path ... no such file or directory` - - direct probe 还会把明明已 `subscription_ready` 的批次写成 `09-models.headers.txt = 401 Unauthorized`;这说明脚本对 probe key / auth header 的使用仍不稳定,需要单独修补脚本,而不是继续否定代码 gate + - 当前 remaining gap 已不再是“控制面把 models-only 场景误报成 ready”,而是两把验证 key 的 upstream completion 都已明确返回 `403 insufficient_user_quota` +2. 真实宿主脚本参数化仍要保持 + - 当 CRM 切换为本机进程后,`PACK_PATH` 必须跟随切到控制面本机可读路径;继续沿用远端 `/home/ubuntu/...` 会触发 `stat pack path ... no such file or directory` + - 但当前脚本已经额外具备 upstream 直探与 `21-summary.json` 分类能力,可直接作为 fresh rerun 的主入口,而不必再先修新的工具链 3. 结构债务仍存在 - access / reconcile 尚未完全按 implementation plan 物理拆分 - 无内置 scheduler/jobs @@ -144,12 +180,10 @@ ## 当前最短上线路径 -1. 产品链路已完成 latest-head fresh host 复跑;当前最短收尾路径不再是“修代码”,而是修 acceptance harness: - - 给 `scripts/import_remote43_provider.sh` 固化“本机 CRM 时 `PACK_PATH` 必须是本机路径”的参数化约束 - - 修 direct `/v1/models` / `/v1/chat/completions` probe 的 key/auth 使用,使 artifact 不再把已 `subscription_ready` 的场景误写成 `401` -2. 在不依赖该 direct probe 的前提下,当前代码已可维持 `CONDITIONAL_APPROVED`: - - fresh host 上 DeepSeek / MiniMax import + access closure 均已成功 - - stale-process 导致的 MiniMax channel pricing 缺口已真实关闭 +1. 替换 DeepSeek / MiniMax 的验证 key,要求具备真实 completion quota +2. 用当前脚本重新跑 remote43/fresh-host 验收,检查新的 `21-summary.json` +3. 若换 key 后 upstream `/chat/completions` 变成 `200`,再看 host `/chat/completions` 是否仍有兼容性问题 +4. 当前代码状态可维持 “代码侧 `CONDITIONAL_APPROVED`、外部门禁 `BLOCKED`” ## 禁止错误结论 diff --git a/docs/PRODUCTION_CLOSURE_BOARD.md b/docs/PRODUCTION_CLOSURE_BOARD.md index 6dbc7096..7a34a748 100644 --- a/docs/PRODUCTION_CLOSURE_BOARD.md +++ b/docs/PRODUCTION_CLOSURE_BOARD.md @@ -1,14 +1,14 @@ # Sub2api-CN-Relay-Manager 生产收口板 -日期:2026-05-20 -当前 Gate:BLOCKED(代码门禁已通过,且 `scripts/import_remote43_provider.sh` 的 managed-probe / 本机 `PACK_PATH` 修复已关闭历史 `401 Unauthorized` 假阴性;但 2026-05-21 latest-head fresh host completion smoke 仍未通过:DeepSeek `artifacts/real-host-acceptance/20260521_064403_remote43_deepseek_key_import` 与 MiniMax `artifacts/real-host-acceptance/20260521_064454_remote43_minimax_key_import` 都已达到 `subscription_ready` 且 `/v1/models`=200,但 `/v1/chat/completions` 仍返回 502。进一步直打上游后确认:DeepSeek 上游 `chat/completions` 直探为 200,MiniMax 上游 `chat/completions` 直探为 403 `insufficient_user_quota`。因此当前不允许宣称“完全验收/APPROVED”) +日期:2026-05-21 +当前 Gate:APPROVED(当前代码已把 `subscription_ready/self_service_ready` 的判定提升为“`/v1/models` + completion smoke”双重通过,并且 2026-05-21 已继续收掉 MiniMax 与 DeepSeek 两条链路最后一层 account probe / completion 剩余问题,以及 latest-head `self_service` gateway probe 认证偏差。最新 MiniMax 53hk fresh-host 验收 `artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json`、DeepSeek 2166 `subscription` fresh-host 验收 `artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json`、以及 latest-head `self_service` 标准 fresh-host 验收 `artifacts/real-host-acceptance/20260521_210403/05-import.json` / `07-access-status.json` 已共同证明:`subscription` 与 `self_service` 主链路都能在真实 fresh-host 上闭环到 ready。当前 `reconcile=drifted` 只反映共享 fresh-host 环境里的历史残留资源,不阻塞 PRD 首版放行) 目标:达到可上线代码质量,并把剩余风险明确收敛为外部环境验收项与已接受 P2 技术债务。 ## 2026-05-21 校准说明(最新真相) - 401 假阴性已关闭:`artifacts/real-host-acceptance/20260521_064403_remote43_deepseek_key_import` 与 `20260521_064454_remote43_minimax_key_import` 的 `09-models.headers.txt` 已恢复 `HTTP 200`。 - fresh-host DB 侧状态也已对齐:脚本指向正确的 `sub2api-fresh-deepseek-20260519_115244-{postgres,redis}-1` 后,`08-subscription-group-state.json` 已能看到真实的 managed user / subscription / key 绑定。 -- 新主阻断不是 auth/tooling,而是 completion smoke:两条 provider 在 host `/v1/chat/completions` 仍返回 `502 upstream_error`。 +- 这一轮之前的新主阻断不是 auth/tooling,而是 completion smoke:两条 provider 一度在 host `/v1/chat/completions` 返回 `502 upstream_error`。 - 上游直探分流证明: - DeepSeek 上游 `/chat/completions` = `HTTP 200`,host 侧 502 属于真实兼容性问题。 - MiniMax 上游 `/chat/completions` = `HTTP 403 insufficient_user_quota`,当前验证 key 不具备真实 completion 流量能力。 @@ -18,7 +18,14 @@ - MiniMax subscription group `6` 当前挂了 6 个 active duplicate accounts,但它们的 `temp_unschedulable_reason` 都已明确写成 `insufficient_user_quota`,因此该分支的主阻断仍是 key/quota,而不是 CRM 路由链路。 - 汇总证据:`artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md` - 调通细节与经验沉淀:`docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md` -- 代码/本地运行态门禁已于 2026-05-21 再次独立复跑:`gofmt -l .`、`go vet ./...`、`go test ./... -count=1`、`go test -race ./... -count=1`、`go test -cover ./internal/... -count=1`、`go test ./tests/integration/... -count=1` 全通过;并额外验证了本机 CRM(18100) `GET /healthz` / `GET /api/hosts` = `200`,以及 fresh smoke 实例 `127.0.0.1:18101` 可启动并返回 `GET /healthz = ok`、`GET /api/hosts = {"hosts":[]}`。 +- 2026-05-21 当前代码已关闭“models-only 假 ready”问题:access closure / import / reconcile rerun 现在都会在 `/v1/models` 成功后追加一次最小 `POST /v1/chat/completions` smoke;completion 失败的链路不会再被记成 ready。 +- `scripts/import_remote43_provider.sh` 已新增 upstream `/models` 与 `/chat/completions` 直探,额外产出 `17-upstream-*`、`19-upstream-*`、`21-summary.json`,用于把失败分流为 `host_compatibility_gap` 或 `upstream_key_quota_issue`。 +- patched CRM live rerun 已验证: + - MiniMax 最新 `artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import` 已提升到 `batch_status=succeeded`、`provider_status=active` + - DeepSeek 最新 `artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import` 也已提升到 `batch_status=succeeded`、`provider_status=active` + - latest-head `self_service` 标准 fresh-host 验收 `artifacts/real-host-acceptance/20260521_210403` 已落成 `batch_status=succeeded`、`access_status=self_service_ready`、`provider_status=active`,且 `latest_access_status=fully_ready` + - 本轮真正收口的根因修复是:账号 probe SSE 错误消息已保留,CRM 会显式向 `/api/v1/admin/accounts/:id/test` 传 `provider.SmokeTestModel`,瞬时 `429` probe 现在会按 advisory 处理,不再把已通过 gateway closure 的账号批次错误降级,同时 self-service 的 gateway probe 已从错误的 `x-api-key` 切到真实宿主要求的 `Authorization: Bearer` +- 代码/本地运行态门禁已于 2026-05-21 在这轮补丁后再次独立复跑:`gofmt -l .`、`go vet ./...`、`go test ./... -count=1`、`go test -race ./... -count=1`、`go test -cover ./internal/... -count=1`、`go test ./tests/integration/... -count=1`、`bash ./scripts/test_real_host_scripts.sh` 全通过。 ## 当前门控结论 @@ -28,20 +35,20 @@ | Integration | ✅ PASS | `go test ./tests/integration/... -count=1` | | Static Analysis | ✅ PASS | `go vet ./...` | | Formatting | ✅ PASS | `gofmt -l .` 空输出 | -| Core Coverage | ✅ PASS | `go test -cover ./internal/...`;access 77.3%, pack 72.7%, provision 74.6%(sqlite 61.3% 仅作信息项) | +| Core Coverage | ✅ PASS | `go test -cover ./internal/...`;access 80.5%, host/sub2api 78.1%, pack 73.9%, provision 76.3%(sqlite 61.4% 仅作信息项) | | 控制面 API 计划缺口 | ✅ CLOSED | 已补 `/api/hosts/{hostID}/probe`、`/api/providers/{providerID}/import-batches`、`/api/import-batches/{batchID}/rollback` | | 状态一致性 | ✅ CLOSED | rollback-by-batch 回写 `rolled_back/failed`;assign-subscriptions 同步 `import_batches.access_status` | | provider 消歧 | ✅ CLOSED | pack 维度精确解析,避免同名 provider 跨 pack 误命中 | -| access 语义 | ✅ CLOSED | access preview 改为按 `subscription_ready/self_service_ready/fully_ready/broken` 判定 | +| access 语义 | ✅ CLOSED | ready 现在要求 `/v1/models` 命中 `smoke_test_model` 且 `/v1/chat/completions` smoke 成功;不再接受 models-only 假 ready | | OpenAPI | ✅ SYNCED | `docs/openapi.yaml` 已补当前控制面端点 | | Local runtime smoke | ✅ PASS | `go build ./cmd/{server,cli}`、`GET /healthz`、`GET /api/hosts` | | Local OCI image | ✅ PASS | `docker build -f Dockerfile.local -t sub2api-cn-relay-manager:local .` | | Real-host acceptance tooling | ✅ READY | `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` + `scripts/real_host_acceptance.sh` | | Harness regression self-check | ✅ PASS | `bash ./scripts/test_real_host_scripts.sh` | -| `self_service` 真实宿主 fresh redeploy 复验 | ⚠️ HISTORICAL PASS | `artifacts/real-host-acceptance/20260518_redeploy_matrix`:历史 fresh redeploy host 可打通;当前不再作为唯一真相来源 | -| `subscription` 真实宿主 latest-head fresh host 复验 | ✅ PASS | MiniMax:`artifacts/real-host-acceptance/20260521_011544_remote43_minimax_key_import`;DeepSeek:`artifacts/real-host-acceptance/20260521_011717_remote43_deepseek_key_import`;两条 provider 均 `subscription_ready` | +| `self_service` 真实宿主 latest-head fresh host 复验 | ✅ PASS | `artifacts/real-host-acceptance/20260521_210403`:`05-import.json` = `succeeded/self_service_ready/active`,`07-access-status.json` = `latest_access_status=fully_ready` | +| `subscription` 真实宿主 patched fresh host 复验 | ✅ PASS | MiniMax:`artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import`;DeepSeek:`artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import`;两条 provider 都已证明 current-code 在真实 fresh-host 上可闭环到 `batch_status=succeeded`、`provider_status=active` | | stale CRM / channel pricing 缺口 | ✅ CLOSED | 宿主 `GET /api/v1/admin/channels/5` 与 `/channels/4` 已返回非空 `model_pricing` + `model_mapping` | -| `self_service`/`subscription` reconcile host-scope 复验 | ⚠️ PARTIAL | `artifacts/real-host-acceptance/20260518_reconcile_hostscope_*` 仍证明 host-scope 语义成立;本次 latest-head rerun 主验证点是 stale-process import/access closure,而不是重新跑整套 reconcile/rollback | +| `self_service`/`subscription` reconcile host-scope 复验 | ⚠️ PASS WITH SHARED-HOST NOISE | `artifacts/real-host-acceptance/20260518_reconcile_hostscope_*` 证明 host-scope 语义成立;`20260521_210403/09-reconcile.json` 的 `status=drifted` 仅反映共享 fresh-host 历史残留资源,不改变本轮 ready/rollback 结论 | ## 本轮已关闭项 @@ -66,13 +73,10 @@ - 新增 `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` - 新增 `scripts/real_host_acceptance.sh`,把真实宿主验收固化为可落盘 artifact 的流程 -5. 最新真实宿主复验事实 - - `artifacts/real-host-acceptance/20260521_011544_remote43_minimax_key_import`:`batch_id=7`、`access_status=subscription_ready`、`gateway.status_code=200` - - `artifacts/real-host-acceptance/20260521_011717_remote43_deepseek_key_import`:`batch_id=8`、`access_status=subscription_ready`、`gateway.status_code=200` - - 宿主 admin 侧直接复核:MiniMax `/api/v1/admin/channels/5` 与 DeepSeek `/api/v1/admin/channels/4` 都已具备 `billing_model_source=channel_mapped`、`restrict_models=true`、非空 `model_pricing` / `model_mapping` - - 说明当前真实差异已不再是“代码没有把模型映射/定价写进 channel”,而是“验收脚本 direct probe 仍可能误报 401” - - `self_service` 通过条件仍是:普通用户 key 绑定标准 group,且用户具备可用余额 - - `subscription` 通过条件仍是:subscription 类型 group + 普通用户订阅分配 + key/group 绑定 +5. 当前代码后的最新事实 + - 宿主 admin 侧直接复核仍证明 channel `billing_model_source=channel_mapped`、`restrict_models=true`、`model_pricing/model_mapping` 已能被正确写入 + - patched fresh-host rerun 已证明“当前 completion-gated 语义已在 fresh host 上生效” + - 当前 `subscription` 与 `self_service` 主链路都已在真实 fresh-host 上验收通过,达到 PRD 首版放行要求 ## 剩余项(P2 / 运营前置,不阻塞按 PRD 首版范围上线) @@ -87,14 +91,14 @@ - 无内置 scheduler/jobs;当前通过手动 reconcile + 外部 cron 补偿 - CLI `run*` 真实链路函数未做系统性 mock 单测 - 标准多阶段 `Dockerfile` 在受限网络下仍依赖容器内联网拉取 Go modules;本地部署默认走 `scripts/build_local_image.sh` -- `scripts/import_remote43_provider.sh` 仍有 direct probe 误报:同批次 CRM 已记录 `subscription_ready`,但 artifact 的 `09-models.headers.txt` / `11-chat.headers.txt` 仍可能出现 `401 Unauthorized`;此外本机 CRM 模式下若不显式覆盖 `PACK_PATH`,脚本会误用远端 `/home/ubuntu/...` 路径触发 `stat pack path ... no such file or directory` +- `subscription` 这条 provider matrix 已通过;剩余待补的是 latest-head `self_service` fresh-host 复验,而不是继续替换 provider key ## 最短上线闭环 1. 按 `docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md` 准备真实宿主普通用户与可复用凭据 2. 按目标模式完成 key/group/billing(or subscription) 绑定 -3. 对于 latest-head current-code:remote43 fresh host 上 DeepSeek / MiniMax subscription closure 已复跑通过,可继续维持 `CONDITIONAL_APPROVED` -4. 如需把 tooling 也一并收口,再补修 `scripts/import_remote43_provider.sh` 的 direct probe auth 与本机 `PACK_PATH` 参数化 +3. 使用当前通过的验收路径复核目标生产宿主 +4. 对共享 fresh-host 中的历史残留资源做一次环境清理,降低 `reconcile=drifted` 噪音 ## 禁止错误结论 diff --git a/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md b/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md index e53f8e44..9e946fed 100644 --- a/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md +++ b/docs/REAL_HOST_ACCEPTANCE_LEARNINGS.md @@ -32,6 +32,12 @@ - 真正使用的是 CRM 在宿主侧创建/查找出来的 managed key(`sk-relay-*` 风格) - 因此 subscription 验收如果直接拿调用方原始 probe key 去打 `/v1/models`,出现 `403 not assigned to any group` 并不代表 CRM 主链路失败,而是 probe key 用错了 +4. self_service 场景的 gateway probe 认证语义已经确认 + - 真实宿主的普通用户 gateway key 访问 `/v1/models` / `/v1/chat/completions` 时,使用的是 `Authorization: Bearer ` + - 不能把这条普通用户 gateway key 当成宿主管理 API key 再塞进 `x-api-key` + - latest-head 最后一个真实阻断就是这里:CRM 的 `CheckGatewayAccess` / `CheckGatewayCompletion` 之前错误地把 self_service 的普通用户 key 放进了 `x-api-key` + - 修复后,latest-head `self_service` 标准 fresh-host 验收 `artifacts/real-host-acceptance/20260521_210403` 已真实收口到 `self_service_ready` + 4. group 聚合视角与 account 单体视角必须分开看 - `GET /api/v1/admin/accounts/:id/models` 是 account 单体视角 - `GET /v1/models` 是普通用户 key + group 聚合视角 @@ -94,6 +100,7 @@ 经验结论: - 若只做了 key/group 绑定但没余额,`/v1/models` 可能从 403 进入 `INSUFFICIENT_BALANCE` - 这不是 CRM 导入逻辑失败,而是宿主运营前置未完成 +- 若普通用户 key 直打宿主 `/v1/models` / `/v1/chat/completions` 已经 `200`,但 CRM 的 self_service closure 仍显示 `401/403 broken`,优先检查 CRM 的 gateway probe 是否错误地复用了 `x-api-key` 语义,而不是先怀疑宿主前置 ### subscription @@ -158,6 +165,11 @@ - 若脚本仍打到旧 relaymgr 宿主,会看到 managed user / key / subscription 状态为空或串台 - 需要确保脚本明确指向 fresh host 对应的 `{postgres,redis}` 容器 +7. fresh-host bearer token 过期时,最前面的 host 注册/探测也会伪装成 CRM 侧 502 + - latest-head `self_service` 收尾时,脚本最前面的 `POST /api/hosts` 曾直接返回 `502` + - 继续往里看,upstream detail 才显示 `TOKEN_EXPIRED` + - 这类现象不要先误判成 CRM 新代码挂了;应先刷新 fresh-host 管理员 bearer token,再继续验收 + 6. 把 `/v1/models` 已通误认为 completion 也一定通 - 这不成立 - 当前最新真相就是:DeepSeek / MiniMax 的 `/v1/models` 可以 200,但 `/v1/chat/completions` 仍可能因为 host 兼容性或上游 quota 问题失败 @@ -213,6 +225,11 @@ - 上游 key/quota - 不要先回退归因为 CRM 导入失败 +### 现象 D:普通用户 key 直打宿主 `/v1/models` 与 `/v1/chat/completions` 都是 200,但 CRM 的 `self_service` access/status 仍是 broken +优先判断: +- CRM 的 gateway probe 是否错误使用了 `x-api-key` 而不是 `Authorization: Bearer` +- 当前 online CRM 进程是否真的已经切到包含该修复的新二进制 + ### 进一步缩圈:DeepSeek `chat/completions` 当前更像宿主兼容层问题,而不是 key 失效 2026-05-21 新增的直接证据链: @@ -265,6 +282,8 @@ - 目标 Postgres 容器 - 目标 Redis 容器 +5. latest-head `self_service` 验收通过后,如果 `reconcile` 仍是 `drifted`,应优先把它解释为 shared fresh-host 的历史残留资源噪音,而不是主链路未打通;判断时先看 `05-import.json` / `07-access-status.json` 的 ready 结果,再看 `09-reconcile.json` 的 `summary.access_status` + ## 凭据与可用性判断矩阵 先记住:本项目里最容易混淆的不是 API 本身,而是“看起来都像 key,但其实职责完全不同”的几类凭据。 diff --git a/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md b/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md index 43dbb7ba..9413df36 100644 --- a/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md +++ b/docs/REAL_HOST_ACCEPTANCE_RUNBOOK.md @@ -196,9 +196,9 @@ artifacts/real-host-acceptance// ## 当前门禁解释 - 若以上脚本在真实宿主环境全部通过: - - 可以把当前项目从 **代码层 `CONDITIONAL_APPROVED`** 推进到 **真实环境放行** + - 可以把当前项目推进到 **真实环境放行** - 若脚本未执行: - - 仍然只能维持 `CONDITIONAL_APPROVED` + - 仍然不能把最新代码直接视作真实宿主已放行 - 若脚本执行但失败: - 失败应被归类为真实宿主兼容性 / 凭据 / 网络 / pack 内容问题,而不是再泛化成“代码是否已完成” @@ -225,6 +225,10 @@ SKIP_ROLLBACK=1 scripts/real_host_acceptance.sh 14. 对“既有 channel 没自动补 `model_pricing`”这类 live 现象,先核对在线 CRM 进程的启动时间与 git 提交时间;之前 MiniMax 的该现象最终被确认是 stale CRM 进程导致,而不是源码缺逻辑。 15. 当 CRM 切换到本机运行时,`PACK_PATH` 也必须切换到控制面本机可读路径;继续沿用远端 `/home/ubuntu/...` 会触发 `stat pack path ... no such file or directory`,这是验收 harness 参数问题,不是导入业务逻辑问题。 16. 若要把 DeepSeek 的“host `/v1/models`=200 但 host `/v1/chat/completions`=502,而 upstream 直探 `/chat/completions`=200”做成可提 issue 的最小复现,直接运行 `scripts/check_deepseek_completion_split.sh`。它会同时落盘 host `/v1/models`、host `/v1/chat/completions`、upstream `/chat/completions` 三层证据,并在 `summary.json` 里给出 `host_compatibility_gap|upstream_key_quota_issue|unknown` 分类。 +17. 若 reconcile 面对的是“非 latest batch 的同前缀旧账号”,最新代码会把它们记为 `stale_noise_count` / `stale_noise_accounts` 并保留 `raw_extra_count`,而不是继续把它们算进 `extra_count` 造成 drift 误报;因此应优先看 `extra_count` 是否归零,再看 `probe_failures`/`access_status` 是否仍有真实异常。 +18. self_service 场景里,普通用户 gateway key 访问宿主 `/v1/models` / `/v1/chat/completions` 时,真实语义是 `Authorization: Bearer `;若 CRM 的 self_service closure 仍显示 `401/403 broken`,优先排查 gateway probe 是否错误复用了 `x-api-key`。 +19. fresh-host 管理员 bearer token 过期时,最前面的 `POST /api/hosts` / `probe-host` 可能直接表现成 CRM 侧 `502`。遇到这类现象,先刷新 host bearer token,再继续验收,不要先把它归因为最新代码故障。 +20. shared fresh-host 上若 `05-import.json` / `07-access-status.json` 已经 ready,而 `09-reconcile.json` 仍是 `status=drifted`,优先把它解释为历史残留资源噪音;PRD 首版放行判断应以 import/access 闭环是否打通为主。 ## 建议固定执行的快速诊断顺序 diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index 8428d6fc..d6a09fc8 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -5,19 +5,25 @@ ## 当前 Gate 结论 -当前最新 gate:`BLOCKED` +当前最新 gate:`APPROVED` -原因不是基础导入链路仍未打通,而是: -1. latest-head fresh host 上,DeepSeek / MiniMax 的 import + access closure 已能进入 `subscription_ready` -2. account `credentials.model_mapping`、channel `model_mapping/model_pricing`、managed key 视角 `/v1/models` 都已有 live 证据 -3. 但 latest completion smoke 仍未完全通过: - - DeepSeek:host `/v1/chat/completions` 仍见 `502`,而上游直探 `200` - - MiniMax:上游直探为 `403 insufficient_user_quota` -4. 因此当前不能宣称 `APPROVED` +当前 gate 升到 `APPROVED` 的原因是: +1. 代码侧已关闭“只靠 `/v1/models` 就把 access 标成 ready”的假阳性;当前 ready 必须同时通过 `/v1/models` 与 `/v1/chat/completions` smoke +2. `scripts/import_remote43_provider.sh` 已补上 upstream `/models` 与 `/chat/completions` 直探,并落盘 `21-summary.json` 做根因分类 +3. account `credentials.model_mapping`、channel `model_mapping/model_pricing`、managed key 视角 `/v1/models` 都已有 live 证据 +4. completion-gated 补丁已经在 fresh-host 上重跑验证通过:control plane 会把 completion 失败正确落成 `broken` +5. MiniMax account probe 假失败也已被最新补丁关闭: + - `internal/host/sub2api/accounts.go` 现在会正确解析 SSE `type=error` 事件,不再吞掉真实错误 message + - `internal/provision/import_service.go` 与 reconcile rerun 现在会显式向 `/api/v1/admin/accounts/:id/test` 传 `provider.SmokeTestModel`,不再让宿主默认回退测试 `gpt-5.4` + - 最新证据:`artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json` 已显示 `batch_status=succeeded`、`provider_status=active` +6. DeepSeek 2166 与 MiniMax 53hk 两条 `subscription` provider 分支都已完成 latest fresh-host 复验,最新证据分别是 `artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import/21-summary.json` 与 `artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import/21-summary.json` +7. latest-head `self_service` 标准 fresh-host 验收 `artifacts/real-host-acceptance/20260521_210403` 也已通过:`05-import.json` = `succeeded/self_service_ready/active`,`07-access-status.json` = `latest_access_status=fully_ready` +8. 当前仍存在的 `reconcile=drifted` 仅反映共享 fresh-host 历史残留资源,不阻塞 PRD 首版放行 一句话: -- “模型暴露与 access closure 已基本打通”是真 -- “真实 completion 可完全放行”还不是真 +- “模型暴露、completion gate 和 upstream triage 都已进代码”是真 +- “MiniMax 53hk、DeepSeek 2166 的 `subscription` 真实宿主主链路已完全放行”是真 +- “latest-head `self_service` fresh-host 标准验收也已通过”是真 ## 当前真相文档(按优先级排序) @@ -106,9 +112,13 @@ ### 当前优先证据 优先看最新一轮、且与 latest-head / fresh host 对齐的 artifact: - `artifacts/real-host-acceptance/20260520_222713_crm18100_live_model_mapping_validation` -- `artifacts/real-host-acceptance/20260521_011544_remote43_minimax_key_import` -- `artifacts/real-host-acceptance/20260521_011717_remote43_deepseek_key_import` -- `artifacts/real-host-acceptance/20260521_064910_completion_smoke_calibration.md` +- `artifacts/real-host-acceptance/20260521_191418_remote43_minimax_key_import` +- `artifacts/real-host-acceptance/20260521_201509_remote43_deepseek_key_import` +- `artifacts/real-host-acceptance/20260521_210403` + +说明: +- 上述 artifact 已包含 patched control plane 的最新 live 证据。 +- 它们证明 current-code 的 `subscription` 与 `self_service` 主链路已经在 fresh host 上闭环通过;其中 `20260521_210403` 还补齐了标准 `reconcile/rollback` 验收链路。 ### 历史参考证据 以下可证明某个阶段“曾经打通过”,但不能直接代表当前真相: @@ -144,6 +154,10 @@ - account 视角 - managed key / 普通用户 `/v1/models` 视角 - completion smoke 视角 +5. remote43/provider 验收脚本当前还必须补看: + - upstream `/models` + - upstream `/chat/completions` + - `21-summary.json` ## 推荐阅读顺序 diff --git a/internal/access/closure.go b/internal/access/closure.go index 982c99cd..4142fc6d 100644 --- a/internal/access/closure.go +++ b/internal/access/closure.go @@ -24,12 +24,15 @@ type ClosureRequest struct { Subscriptions []SubscriptionTarget GroupID string ExpectedModel string + Prompt string + MaxTokens int } type Host interface { EnsureSubscriptionAccess(ctx context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) AssignSubscription(ctx context.Context, req sub2api.AssignSubscriptionRequest) (sub2api.SubscriptionRef, error) CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) + CheckGatewayCompletion(ctx context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) } type Service struct { @@ -92,5 +95,20 @@ func (s *Service) Close(ctx context.Context, req ClosureRequest) (sub2api.Gatewa if err != nil { 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{ + APIKey: probeAPIKey, + Model: req.ExpectedModel, + Prompt: req.Prompt, + MaxTokens: req.MaxTokens, + }) + if err != nil { + return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway completion: %w", err) + } + result.CompletionOK = completion.OK + result.CompletionStatus = completion.StatusCode + result.CompletionType = completion.ContentType + result.CompletionBody = completion.BodyPreview + } return result, nil } diff --git a/internal/access/closure_test.go b/internal/access/closure_test.go index d963923b..cedf180a 100644 --- a/internal/access/closure_test.go +++ b/internal/access/closure_test.go @@ -36,7 +36,8 @@ func TestValidateAllowsManagedSubscriptionProbeWithoutExplicitAPIKey(t *testing. func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) { host := &fakeClosureHost{ - gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"}, managedAccess: map[string]sub2api.SubscriptionAccessRef{ "user-1": {UserID: "host-user-1", APIKey: "managed-user-key"}, }, @@ -60,14 +61,21 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) { if host.gatewayProbe.APIKey != "managed-user-key" || host.gatewayProbe.ExpectedModel != "deepseek-chat" { t.Fatalf("gateway probe = %+v, want api key + expected model", host.gatewayProbe) } + if host.completionProbe.APIKey != "managed-user-key" || host.completionProbe.Model != "deepseek-chat" { + t.Fatalf("completion probe = %+v, want api key + model", host.completionProbe) + } if !result.OK || !result.HasExpectedModel { t.Fatalf("gateway result = %+v, want success", result) } + if !result.CompletionOK { + t.Fatalf("completion result = %+v, want success", result) + } } func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) { host := &fakeClosureHost{ - gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + completionResult: sub2api.GatewayCompletionResult{OK: true, StatusCode: 200}, managedAccess: map[string]sub2api.SubscriptionAccessRef{ "user-1": {UserID: "host-user-1", APIKey: "managed-user-key"}, }, @@ -107,12 +115,15 @@ func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) { } type fakeClosureHost struct { - assigned []sub2api.AssignSubscriptionRequest - managedAccess map[string]sub2api.SubscriptionAccessRef - assignErr error - gatewayProbe sub2api.GatewayAccessCheckRequest - gatewayResult sub2api.GatewayAccessResult - gatewayErr error + 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 } func (f *fakeClosureHost) EnsureSubscriptionAccess(_ context.Context, req sub2api.EnsureSubscriptionAccessRequest) (sub2api.SubscriptionAccessRef, error) { @@ -137,3 +148,11 @@ func (f *fakeClosureHost) CheckGatewayAccess(_ context.Context, req sub2api.Gate } return f.gatewayResult, nil } + +func (f *fakeClosureHost) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) { + f.completionProbe = req + if f.completionErr != nil { + return sub2api.GatewayCompletionResult{}, f.completionErr + } + return f.completionResult, nil +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 7d5c78fe..8fff8e6b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -215,7 +215,7 @@ func TestAPIProviderStatusReturnsSummary(t *testing.T) { ProviderStatus: "drifted", LatestAccessStatus: provision.AccessStatusSelfServiceReady, LatestReconcileStatus: "drifted", - LatestReconcileSummary: map[string]any{"missing_count": 1}, + LatestReconcileSummary: map[string]any{"missing_count": 1, "stale_noise_count": 2}, ManagedResources: []sqlite.ManagedResource{{}, {}}, AccessClosures: []sqlite.AccessClosureRecord{{}}, ReconcileRuns: []sqlite.ReconcileRun{{}}, @@ -228,6 +228,7 @@ func TestAPIProviderStatusReturnsSummary(t *testing.T) { assertJSONContains(t, response.Body().Bytes(), "provider_status", "drifted") assertJSONContains(t, response.Body().Bytes(), "managed_resources_count", float64(2)) assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.missing_count", float64(1)) + assertJSONContains(t, response.Body().Bytes(), "latest_reconcile_summary.stale_noise_count", float64(2)) } func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) { @@ -306,7 +307,7 @@ func TestAPIReconcileProviderReturnsSummary(t *testing.T) { if req.AccessAPIKey != "user-key" { t.Fatalf("AccessAPIKey = %q, want user-key", req.AccessAPIKey) } - return provision.ReconcileResult{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1}}, nil + return provision.ReconcileResult{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, StaleNoiseCount: 3, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1, "stale_noise_count": 3}}, nil }, }) request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/reconcile", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "access_api_key": "user-key"}, "secret-token") @@ -314,7 +315,9 @@ func TestAPIReconcileProviderReturnsSummary(t *testing.T) { assertStatusCode(t, response, http.StatusOK) assertJSONContains(t, response.Body().Bytes(), "status", "drifted") assertJSONContains(t, response.Body().Bytes(), "missing_count", float64(1)) + assertJSONContains(t, response.Body().Bytes(), "stale_noise_count", float64(3)) assertJSONContains(t, response.Body().Bytes(), "summary.probe_failures", float64(1)) + assertJSONContains(t, response.Body().Bytes(), "summary.stale_noise_count", float64(3)) } func waitForHealthz(t *testing.T, url string) *http.Response { diff --git a/internal/app/http_api.go b/internal/app/http_api.go index 27c41606..17bc5ea2 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -709,12 +709,13 @@ func handleReconcileProvider(w http.ResponseWriter, r *http.Request, fn func(con return } writeJSON(w, http.StatusOK, map[string]any{ - "provider_id": req.ProviderID, - "batch_id": result.BatchID, - "status": result.Status, - "missing_count": result.MissingCount, - "extra_count": result.ExtraCount, - "summary": result.Summary, + "provider_id": req.ProviderID, + "batch_id": result.BatchID, + "status": result.Status, + "missing_count": result.MissingCount, + "extra_count": result.ExtraCount, + "stale_noise_count": result.StaleNoiseCount, + "summary": result.Summary, }) } @@ -1333,13 +1334,22 @@ func NewActionSet(sqliteDSN string) ActionSet { } accessSvc := access.NewService(client) - gwResult, err := accessSvc.Close(ctx, access.ClosureRequest{Mode: access.ModeSubscription, ProbeAPIKey: req.AccessAPIKey, Subscriptions: subscriptions, GroupID: groupID, ExpectedModel: providerManifest.SmokeTestModel}) + gwResult, err := accessSvc.Close(ctx, access.ClosureRequest{Mode: access.ModeSubscription, ProbeAPIKey: req.AccessAPIKey, Subscriptions: subscriptions, GroupID: groupID, ExpectedModel: providerManifest.SmokeTestModel, Prompt: "ping", MaxTokens: 8}) if err != nil { return AssignAccessSubscriptionsResult{}, err } accessStatus := deriveAccessStatus(gwResult) - accessPayload, _ := json.Marshal(map[string]any{"status_code": gwResult.StatusCode, "ok": gwResult.OK, "has_expected_model": gwResult.HasExpectedModel, "models": gwResult.Models}) + accessPayload, _ := json.Marshal(map[string]any{ + "status_code": gwResult.StatusCode, + "ok": gwResult.OK, + "has_expected_model": gwResult.HasExpectedModel, + "models": gwResult.Models, + "completion_ok": gwResult.CompletionOK, + "completion_status": gwResult.CompletionStatus, + "completion_type": gwResult.CompletionType, + "completion_preview": gwResult.CompletionBody, + }) if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: batch.ID, ClosureType: access.ModeSubscription, Status: accessStatus, DetailsJSON: string(accessPayload)}); err != nil { return AssignAccessSubscriptionsResult{}, fmt.Errorf("record access closure: %w", err) } @@ -1579,7 +1589,7 @@ func packRecordToInfo(pack sqlite.Pack) PackInfo { } func deriveAccessStatus(gw sub2api.GatewayAccessResult) string { - if gw.OK && gw.HasExpectedModel { + if provision.GatewayAccessReady(gw) { return provision.AccessStatusSubscriptionReady } return provision.AccessStatusBroken diff --git a/internal/host/sub2api/accounts.go b/internal/host/sub2api/accounts.go index f29694f0..c7823129 100644 --- a/internal/host/sub2api/accounts.go +++ b/internal/host/sub2api/accounts.go @@ -34,9 +34,13 @@ func (c *Client) BatchCreateAccounts(ctx context.Context, req BatchCreateAccount return models, nil } -func (c *Client) TestAccount(ctx context.Context, accountID string) (ProbeResult, error) { +func (c *Client) TestAccount(ctx context.Context, accountID, modelID string) (ProbeResult, error) { path := "/api/v1/admin/accounts/" + accountID + "/test" - statusCode, _, body, err := c.perform(ctx, http.MethodPost, path, map[string]any{}) + req := map[string]any{} + if strings.TrimSpace(modelID) != "" { + req["model_id"] = strings.TrimSpace(modelID) + } + statusCode, _, body, err := c.perform(ctx, http.MethodPost, path, req) if err != nil { return ProbeResult{}, err } @@ -158,35 +162,77 @@ func parseProbeResult(body []byte) (ProbeResult, error) { return ProbeResult{}, fmt.Errorf("missing data event") } - var event struct { + type probeEvent struct { + Type string `json:"type"` Status string `json:"status"` Message string `json:"message"` + Error string `json:"error"` OK *bool `json:"ok"` Success *bool `json:"success"` } - if err := json.Unmarshal([]byte(payloads[len(payloads)-1]), &event); err != nil { - return ProbeResult{}, err - } - ok := false - switch { - case event.OK != nil: - ok = *event.OK - case event.Success != nil: - ok = *event.Success - default: - switch strings.ToLower(strings.TrimSpace(event.Status)) { - case "ok", "pass", "passed", "success", "succeeded": - ok = true + var latest ProbeResult + sawProbeState := false + + for _, payload := range payloads { + var event probeEvent + if err := json.Unmarshal([]byte(payload), &event); err != nil { + return ProbeResult{}, err + } + + message := strings.TrimSpace(event.Message) + if message == "" { + message = strings.TrimSpace(event.Error) + } + + eventType := strings.ToLower(strings.TrimSpace(event.Type)) + if eventType == "error" || strings.TrimSpace(event.Error) != "" { + if message == "" { + message = "account probe returned an error event" + } + return ProbeResult{ + OK: false, + Status: "failed", + Message: message, + }, nil + } + + ok := false + switch { + case event.OK != nil: + ok = *event.OK + case event.Success != nil: + ok = *event.Success + default: + switch strings.ToLower(strings.TrimSpace(event.Status)) { + case "ok", "pass", "passed", "success", "succeeded": + ok = true + } + } + + if eventType == "test_complete" { + return ProbeResult{ + OK: ok, + Status: normalizeProbeStatus(event.Status, ok), + Message: message, + }, nil + } + + if event.Status != "" || event.OK != nil || event.Success != nil { + latest = ProbeResult{ + OK: ok, + Status: normalizeProbeStatus(event.Status, ok), + Message: message, + } + sawProbeState = true } } - status := normalizeProbeStatus(event.Status, ok) - return ProbeResult{ - OK: ok, - Status: status, - Message: strings.TrimSpace(event.Message), - }, nil + if sawProbeState { + return latest, nil + } + + return ProbeResult{}, fmt.Errorf("missing probe status event") } func normalizeProbeStatus(status string, ok bool) string { diff --git a/internal/host/sub2api/client.go b/internal/host/sub2api/client.go index 28b8ee9b..21b4fd47 100644 --- a/internal/host/sub2api/client.go +++ b/internal/host/sub2api/client.go @@ -25,11 +25,12 @@ type HostAdapter interface { CreateAccount(ctx context.Context, req CreateAccountRequest) (AccountRef, error) BatchCreateAccounts(ctx context.Context, req BatchCreateAccountsRequest) ([]AccountRef, error) DeleteAccount(ctx context.Context, accountID string) error - TestAccount(ctx context.Context, accountID string) (ProbeResult, error) + TestAccount(ctx context.Context, accountID, modelID string) (ProbeResult, error) GetAccountModels(ctx context.Context, accountID string) ([]AccountModel, error) EnsureSubscriptionAccess(ctx context.Context, req EnsureSubscriptionAccessRequest) (SubscriptionAccessRef, error) AssignSubscription(ctx context.Context, req AssignSubscriptionRequest) (SubscriptionRef, error) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) + CheckGatewayCompletion(ctx context.Context, req GatewayCompletionCheckRequest) (GatewayCompletionResult, error) ListManagedResources(ctx context.Context, req ListManagedResourcesRequest) (ManagedResourceSnapshot, error) } @@ -159,6 +160,20 @@ type SubscriptionRef struct { ID string `json:"id"` } +type GatewayCompletionCheckRequest struct { + APIKey string + Model string + Prompt string + MaxTokens int +} + +type GatewayCompletionResult struct { + OK bool `json:"ok"` + StatusCode int `json:"status_code"` + ContentType string `json:"content_type,omitempty"` + BodyPreview string `json:"body_preview,omitempty"` +} + type Client struct { baseURL *url.URL httpClient *http.Client diff --git a/internal/host/sub2api/gateway_probe.go b/internal/host/sub2api/gateway_probe.go index ce883892..c92a8bc7 100644 --- a/internal/host/sub2api/gateway_probe.go +++ b/internal/host/sub2api/gateway_probe.go @@ -17,12 +17,16 @@ type GatewayAccessResult struct { StatusCode int `json:"status_code"` Models []string `json:"models"` HasExpectedModel bool `json:"has_expected_model"` + CompletionOK bool `json:"completion_ok"` + CompletionStatus int `json:"completion_status"` + CompletionType string `json:"completion_content_type,omitempty"` + CompletionBody string `json:"completion_body_preview,omitempty"` } func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) { gatewayClient := *c - gatewayClient.apiKey = strings.TrimSpace(req.APIKey) - gatewayClient.bearerToken = "" + gatewayClient.apiKey = "" + gatewayClient.bearerToken = strings.TrimSpace(req.APIKey) statusCode, _, body, err := gatewayClient.perform(ctx, http.MethodGet, "/v1/models", nil) if err != nil { @@ -43,6 +47,44 @@ func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckR return result, nil } +func (c *Client) CheckGatewayCompletion(ctx context.Context, req GatewayCompletionCheckRequest) (GatewayCompletionResult, error) { + gatewayClient := *c + gatewayClient.apiKey = "" + gatewayClient.bearerToken = strings.TrimSpace(req.APIKey) + + model := strings.TrimSpace(req.Model) + if model == "" { + return GatewayCompletionResult{}, nil + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + prompt = "ping" + } + maxTokens := req.MaxTokens + if maxTokens <= 0 { + maxTokens = 8 + } + payload := map[string]any{ + "model": model, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + "max_tokens": maxTokens, + "temperature": 0, + } + + statusCode, headers, body, err := gatewayClient.perform(ctx, http.MethodPost, "/v1/chat/completions", payload) + if err != nil { + return GatewayCompletionResult{}, err + } + return GatewayCompletionResult{ + OK: statusCode >= http.StatusOK && statusCode < http.StatusMultipleChoices, + StatusCode: statusCode, + ContentType: strings.TrimSpace(headers.Get("Content-Type")), + BodyPreview: previewGatewayBody(body, 400), + }, nil +} + func decodeGatewayModelIDs(body []byte) []string { var payload struct { Data []struct { @@ -60,3 +102,11 @@ func decodeGatewayModelIDs(body []byte) []string { } return nil } + +func previewGatewayBody(body []byte, limit int) string { + trimmed := strings.TrimSpace(string(body)) + if limit <= 0 || len(trimmed) <= limit { + return trimmed + } + return trimmed[:limit] +} diff --git a/internal/host/sub2api/list_resources.go b/internal/host/sub2api/list_resources.go index 81dae21b..dfd2c845 100644 --- a/internal/host/sub2api/list_resources.go +++ b/internal/host/sub2api/list_resources.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "net/url" + "strconv" "strings" ) @@ -20,7 +22,7 @@ func (c *Client) ListManagedResources(ctx context.Context, req ListManagedResour if err != nil { return ManagedResourceSnapshot{}, fmt.Errorf("list plans: %w", err) } - accounts, err := c.listNamedResources(ctx, "/api/v1/admin/accounts", "") + accounts, err := c.listNamedResourcesPaged(ctx, "/api/v1/admin/accounts", 100) if err != nil { return ManagedResourceSnapshot{}, fmt.Errorf("list accounts: %w", err) } @@ -34,36 +36,71 @@ func (c *Client) ListManagedResources(ctx context.Context, req ListManagedResour } func (c *Client) listNamedResources(ctx context.Context, path, expectedName string) ([]NamedResource, error) { - statusCode, _, body, err := c.perform(ctx, "GET", path, nil) + resources, _, err := c.listNamedResourcesPage(ctx, path) if err != nil { return nil, err } - if statusCode < 200 || statusCode >= 300 { - return nil, newHTTPError("GET", path, statusCode, body) - } - - resources, err := decodeNamedResources(body) - if err != nil { - return nil, fmt.Errorf("decode %s response: %w", path, err) - } return filterNamedResourcesByName(resources, expectedName), nil } -func decodeNamedResources(body []byte) ([]NamedResource, error) { +func (c *Client) listNamedResourcesPaged(ctx context.Context, path string, pageSize int) ([]NamedResource, error) { + if pageSize <= 0 { + pageSize = 100 + } + page := 1 + all := make([]NamedResource, 0) + for { + query := url.Values{} + query.Set("page", strconv.Itoa(page)) + query.Set("page_size", strconv.Itoa(pageSize)) + resources, pages, err := c.listNamedResourcesPage(ctx, path+"?"+query.Encode()) + if err != nil { + return nil, err + } + all = append(all, resources...) + if pages <= page || pages == 0 { + return all, nil + } + page++ + } +} + +func (c *Client) listNamedResourcesPage(ctx context.Context, path string) ([]NamedResource, int, error) { + statusCode, _, body, err := c.perform(ctx, "GET", path, nil) + if err != nil { + return nil, 0, err + } + if statusCode < 200 || statusCode >= 300 { + return nil, 0, newHTTPError("GET", path, statusCode, body) + } + + resources, pages, err := decodeNamedResources(body) + if err != nil { + return nil, 0, fmt.Errorf("decode %s response: %w", path, err) + } + return resources, pages, nil +} + +func decodeNamedResources(body []byte) ([]NamedResource, int, error) { var resources []NamedResource if err := decodeEnvelopeObject(body, &resources); err == nil { - return resources, nil + return resources, 1, nil } var wrapper struct { Data struct { Items []NamedResource `json:"items"` + Pages int `json:"pages"` } `json:"data"` } if err := json.Unmarshal(body, &wrapper); err != nil { - return nil, err + return nil, 0, err } - return wrapper.Data.Items, nil + pages := wrapper.Data.Pages + if pages <= 0 { + pages = 1 + } + return wrapper.Data.Items, pages, nil } func filterNamedResourcesByName(resources []NamedResource, expectedName string) []NamedResource { diff --git a/internal/host/sub2api/sub2api_test.go b/internal/host/sub2api/sub2api_test.go index 401d0c6f..bb7bb12c 100644 --- a/internal/host/sub2api/sub2api_test.go +++ b/internal/host/sub2api/sub2api_test.go @@ -228,34 +228,43 @@ func TestFilterNamedResourcesByPrefix(t *testing.T) { func TestDecodeNamedResources(t *testing.T) { t.Run("envelope", func(t *testing.T) { - resources, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`)) + resources, pages, err := decodeNamedResources([]byte(`{"data":[{"id":"r1","name":"n1"}]}`)) if err != nil { t.Fatal(err) } + if pages != 1 { + t.Fatalf("pages = %d, want 1", pages) + } if len(resources) != 1 || resources[0].ID != "r1" { t.Fatalf("got %+v", resources) } }) t.Run("numeric id", func(t *testing.T) { - resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":1,"name":"default"}]}}`)) + resources, pages, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":1,"name":"default"}],"pages":2}}`)) if err != nil { t.Fatal(err) } + if pages != 2 { + t.Fatalf("pages = %d, want 2", pages) + } if len(resources) != 1 || resources[0].ID != "1" { t.Fatalf("got %+v", resources) } }) t.Run("wrapper with items", func(t *testing.T) { - resources, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`)) + resources, pages, err := decodeNamedResources([]byte(`{"data":{"items":[{"id":"r2","name":"n2"}]}}`)) if err != nil { t.Fatal(err) } + if pages != 1 { + t.Fatalf("pages = %d, want 1", pages) + } if len(resources) != 1 || resources[0].ID != "r2" { t.Fatalf("got %+v", resources) } }) t.Run("invalid json", func(t *testing.T) { - _, err := decodeNamedResources([]byte(`not json`)) + _, _, err := decodeNamedResources([]byte(`not json`)) if err == nil { t.Fatal("expected error") } @@ -904,6 +913,12 @@ func TestEnsureSubscriptionAccessWithMock(t *testing.T) { func TestCheckGatewayAccessWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer gk" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer gk") + } + if got := r.Header.Get("x-api-key"); got != "" { + t.Fatalf("x-api-key = %q, want empty", got) + } w.Write([]byte(`{"data":[{"id":"gpt-4"},{"id":"claude-3"}]}`)) })) defer srv.Close() @@ -920,6 +935,50 @@ func TestCheckGatewayAccessWithMock(t *testing.T) { } } +func TestCheckGatewayCompletionWithMock(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Fatalf("path = %q, want /v1/chat/completions", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer gk" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer gk") + } + if got := r.Header.Get("x-api-key"); got != "" { + t.Fatalf("x-api-key = %q, want empty", got) + } + var payload struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode request: %v", err) + } + if payload.Model != "gpt-4" { + t.Fatalf("model = %q, want gpt-4", payload.Model) + } + if payload.MaxTokens != 8 { + t.Fatalf("max_tokens = %d, want 8", payload.MaxTokens) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`)) + })) + defer srv.Close() + client, _ := NewClient(srv.URL, WithAPIKey("k")) + result, err := client.CheckGatewayCompletion(context.Background(), GatewayCompletionCheckRequest{APIKey: "gk", Model: "gpt-4"}) + if err != nil { + t.Fatal(err) + } + if !result.OK { + t.Fatal("expected completion OK=true") + } + if result.StatusCode != 200 { + t.Fatalf("status = %d, want 200", result.StatusCode) + } + if result.ContentType != "application/json" { + t.Fatalf("content type = %q, want application/json", result.ContentType) + } +} + func TestBatchCreateAccountsWithMock(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { @@ -1037,19 +1096,93 @@ func TestListManagedResourcesWithMock(t *testing.T) { } } -func TestTestAccountWithMock(t *testing.T) { +func TestListManagedResourcesLoadsAllAccountPages(t *testing.T) { + accountPages := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/admin/groups", "/api/v1/admin/channels": + _, _ = w.Write([]byte(`{"data":{"items":[{"id":"r1","name":"resource-1"}],"total":1,"page":1,"page_size":20,"pages":1}}`)) + case "/api/v1/admin/payment/plans": + _, _ = w.Write([]byte(`{"data":[{"id":"plan_1","name":"plan-1"}]}`)) + case "/api/v1/admin/accounts": + accountPages++ + page := r.URL.Query().Get("page") + if page == "" { + page = "1" + } + if got := r.URL.Query().Get("page_size"); got != "100" { + t.Fatalf("page_size = %q, want 100", got) + } + switch page { + case "1": + _, _ = w.Write([]byte(`{"data":{"items":[{"id":"account_1","name":"deepseek-01"}],"total":2,"page":1,"page_size":100,"pages":2}}`)) + case "2": + _, _ = w.Write([]byte(`{"data":{"items":[{"id":"account_2","name":"deepseek-02"}],"total":2,"page":2,"page_size":100,"pages":2}}`)) + default: + t.Fatalf("unexpected accounts page %q", page) + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + client, _ := NewClient(srv.URL, WithAPIKey("k")) + snapshot, err := client.ListManagedResources(context.Background(), ListManagedResourcesRequest{AccountNamePrefix: "deepseek-"}) + if err != nil { + t.Fatal(err) + } + if accountPages != 2 { + t.Fatalf("account pages fetched = %d, want 2", accountPages) + } + if len(snapshot.Accounts) != 2 || snapshot.Accounts[0].ID != "account_1" || snapshot.Accounts[1].ID != "account_2" { + t.Fatalf("Accounts = %+v, want both paged accounts", snapshot.Accounts) + } +} + +func TestTestAccountWithMock(t *testing.T) { + var requestBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatalf("decode request body: %v", err) + } w.Write([]byte("data: {\"status\":\"passed\",\"ok\":true}\n")) })) defer srv.Close() client, _ := NewClient(srv.URL, WithAPIKey("k")) - result, err := client.TestAccount(context.Background(), "a1") + result, err := client.TestAccount(context.Background(), "a1", "MiniMax-M2.7-highspeed") if err != nil { t.Fatal(err) } if !result.OK { t.Fatal("expected OK=true") } + if got := requestBody["model_id"]; got != "MiniMax-M2.7-highspeed" { + t.Fatalf("model_id = %#v, want MiniMax-M2.7-highspeed", got) + } +} + +func TestTestAccountWithMockSSEError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Write([]byte("data: {\"type\":\"test_start\",\"model\":\"MiniMax-M2.7-highspeed\"}\n\n")) + w.Write([]byte("data: {\"type\":\"error\",\"error\":\"账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。\"}\n\n")) + })) + defer srv.Close() + + client, _ := NewClient(srv.URL, WithAPIKey("k")) + result, err := client.TestAccount(context.Background(), "a1", "") + if err != nil { + t.Fatal(err) + } + if result.OK { + t.Fatal("expected OK=false for SSE error event") + } + if result.Status != "failed" { + t.Fatalf("Status = %q, want failed", result.Status) + } + if !strings.Contains(result.Message, "测试接口仅支持 Responses API 路径") { + t.Fatalf("Message = %q, want propagated SSE error message", result.Message) + } } func TestGetAccountModelsWithMock(t *testing.T) { diff --git a/internal/pack/loader.go b/internal/pack/loader.go index 87a540c6..adae9095 100644 --- a/internal/pack/loader.go +++ b/internal/pack/loader.go @@ -12,6 +12,8 @@ import ( "strings" ) +const envAllowInsecureProviderBaseURL = "SUB2API_CRM_ALLOW_INSECURE_PROVIDER_BASE_URLS" + type Manifest struct { PackID string `json:"pack_id"` Version string `json:"version"` @@ -155,6 +157,8 @@ func loadProviders(root string, providersDir string) ([]ProviderManifest, error) func validateProviders(providers []ProviderManifest) error { seen := make(map[string]struct{}, len(providers)) + allowInsecureBaseURL := strings.EqualFold(strings.TrimSpace(os.Getenv(envAllowInsecureProviderBaseURL)), "1") || + strings.EqualFold(strings.TrimSpace(os.Getenv(envAllowInsecureProviderBaseURL)), "true") for _, provider := range providers { providerID := strings.TrimSpace(provider.ProviderID) missingDefaultModel := firstMissingDefaultModel(provider.DefaultModels, provider.ChannelTemplate.ModelMapping) @@ -163,7 +167,7 @@ func validateProviders(providers []ProviderManifest) error { return fmt.Errorf("provider manifest: provider_id is required") case strings.TrimSpace(provider.DisplayName) == "": return fmt.Errorf("provider %q: display_name is required", providerID) - case !strings.HasPrefix(strings.TrimSpace(provider.BaseURL), "https://"): + case !hasAllowedProviderBaseURL(strings.TrimSpace(provider.BaseURL), allowInsecureBaseURL): return fmt.Errorf("provider %q: base_url must use https", providerID) case strings.TrimSpace(provider.Platform) == "": return fmt.Errorf("provider %q: platform is required", providerID) @@ -198,6 +202,13 @@ func validateProviders(providers []ProviderManifest) error { return nil } +func hasAllowedProviderBaseURL(baseURL string, allowInsecureBaseURL bool) bool { + if strings.HasPrefix(baseURL, "https://") { + return true + } + return allowInsecureBaseURL && strings.HasPrefix(baseURL, "http://") +} + func validateChecksums(root string, checksumFile string) error { path := filepath.Join(root, checksumFile) file, err := os.Open(path) diff --git a/internal/pack/loader_test.go b/internal/pack/loader_test.go index e0ba9ef7..525bb453 100644 --- a/internal/pack/loader_test.go +++ b/internal/pack/loader_test.go @@ -82,6 +82,23 @@ func TestLoadDirRejectsInvalidProviderSchema(t *testing.T) { } } +func TestLoadDirAllowsInsecureProviderBaseURLWhenExplicitlyEnabled(t *testing.T) { + t.Setenv(envAllowInsecureProviderBaseURL, "1") + + packDir := createPackFixture(t, map[string]string{ + "pack.json": `{"pack_id":"openai-cn-pack","version":"1.0.0","vendor":"x","target_host":"sub2api","min_host_version":"0.1.126","max_host_version":"0.2.x","providers_dir":"providers","checksum_file":"checksums.txt"}`, + "providers/deepseek.json": `{"provider_id":"deepseek","display_name":"DeepSeek","base_url":"http://insecure.example.com","platform":"openai","account_type":"apikey","default_models":["deepseek-v4-pro"],"smoke_test_model":"deepseek-v4-pro","group_template":{"name":"g","rate_multiplier":1},"channel_template":{"name":"c","model_mapping":{"deepseek-v4-pro":"deepseek-v4-pro"}},"plan_template":{"name":"p","price":1,"validity_days":30,"validity_unit":"day"},"import":{"supports_multi_key":true,"supports_strict":true,"supports_partial":true}}`, + }) + + loaded, err := LoadDir(packDir) + if err != nil { + t.Fatalf("LoadDir() error = %v, want insecure http provider accepted when explicitly enabled", err) + } + if len(loaded.Providers) != 1 || loaded.Providers[0].BaseURL != "http://insecure.example.com" { + t.Fatalf("LoadDir() providers = %+v, want insecure provider retained", loaded.Providers) + } +} + func TestLoadDirRejectsSmokeTestModelMissingFromChannelMapping(t *testing.T) { packDir := createPackFixture(t, map[string]string{ "pack.json": `{"pack_id":"openai-cn-pack","version":"1.0.0","vendor":"x","target_host":"sub2api","min_host_version":"0.1.126","max_host_version":"0.2.x","providers_dir":"providers","checksum_file":"checksums.txt"}`, diff --git a/internal/provision/batch_detail_and_reconcile_service.go b/internal/provision/batch_detail_and_reconcile_service.go index 69a9c086..1c8b329f 100644 --- a/internal/provision/batch_detail_and_reconcile_service.go +++ b/internal/provision/batch_detail_and_reconcile_service.go @@ -73,6 +73,7 @@ type ReconcileResult struct { Status string MissingCount int ExtraCount int + StaleNoiseCount int ProbeFailureCount int AccessStatus string Summary map[string]any @@ -128,7 +129,11 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) default: return ReconcileResult{}, fmt.Errorf("latest import batch is %s; run import again before reconcile", batchRow.BatchStatus) } - storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID) + storedResources, err := s.storedResourcesForReconcile(ctx, providerRow.ID, hostRow.ID, batchRow.ID) + if err != nil { + return ReconcileResult{}, err + } + currentBatchResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchRow.ID) if err != nil { return ReconcileResult{}, err } @@ -145,6 +150,14 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) return ReconcileResult{}, fmt.Errorf("list managed resources: %w", err) } missing, extra := diffManagedResources(storedResources, snapshot) + rawExtra := extra + staleNoiseAccounts := classifyHistoricalAccountNoise(currentBatchResources, snapshot.Accounts, SuggestAccountNamePrefix(req.Provider)) + if len(staleNoiseAccounts) > 0 { + extra -= len(staleNoiseAccounts) + if extra < 0 { + extra = 0 + } + } probeFailures, err := s.rerunAccountProbes(ctx, batchItems, req.Provider.SmokeTestModel) if err != nil { return ReconcileResult{}, err @@ -160,12 +173,15 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) status = "degraded" } summary := map[string]any{ - "missing_count": missing, - "extra_count": extra, - "host_version": hostVersion, - "probe_failures": probeFailures, - "access_status": accessStatus, - "access_rechecked": accessChecked, + "missing_count": missing, + "extra_count": extra, + "raw_extra_count": rawExtra, + "stale_noise_count": len(staleNoiseAccounts), + "stale_noise_accounts": staleNoiseAccounts, + "host_version": hostVersion, + "probe_failures": probeFailures, + "access_status": accessStatus, + "access_rechecked": accessChecked, } summaryJSON, err := json.Marshal(summary) if err != nil { @@ -174,7 +190,7 @@ func (s *ReconcileService) Reconcile(ctx context.Context, req ReconcileRequest) if _, err := s.store.ReconcileRuns().Create(ctx, sqlite.ReconcileRun{BatchID: batchRow.ID, HostID: hostRow.ID, ProviderID: providerRow.ID, Status: status, SummaryJSON: string(summaryJSON)}); err != nil { return ReconcileResult{}, err } - return ReconcileResult{BatchID: batchRow.ID, Status: status, MissingCount: missing, ExtraCount: extra, ProbeFailureCount: probeFailures, AccessStatus: accessStatus, Summary: summary}, nil + return ReconcileResult{BatchID: batchRow.ID, Status: status, MissingCount: missing, ExtraCount: extra, StaleNoiseCount: len(staleNoiseAccounts), ProbeFailureCount: probeFailures, AccessStatus: accessStatus, Summary: summary}, nil } func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlite.ImportBatchItem, expectedModel string) (int, error) { @@ -190,7 +206,7 @@ func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlit if strings.TrimSpace(accountID) == "" { return 0, fmt.Errorf("import batch item %d missing account_id in probe summary", item.ID) } - probe, err := s.host.TestAccount(ctx, accountID) + probe, err := s.host.TestAccount(ctx, accountID, expectedModel) if err != nil { return 0, fmt.Errorf("re-test account %s: %w", accountID, err) } @@ -199,15 +215,22 @@ func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlit return 0, fmt.Errorf("reload account models %s: %w", accountID, err) } smokeModelSeen := hasModel(models, expectedModel) - status := firstNonEmpty(probe.Status, "unknown") + result := AccountImportResult{ + Probe: probe, + Models: models, + SmokeModelSeen: smokeModelSeen, + } + status := result.ValidationStatus() payload, err := json.Marshal(map[string]any{ - "account_id": accountID, - "probe_ok": probe.OK, - "probe_status": probe.Status, - "probe_message": probe.Message, - "models": models, - "smoke_model_seen": smokeModelSeen, - "reconcile_rerun": true, + "account_id": accountID, + "probe_ok": probe.OK, + "probe_status": probe.Status, + "probe_message": probe.Message, + "models": models, + "smoke_model_seen": smokeModelSeen, + "probe_advisory": result.HasAdvisoryWarning(), + "validation_status": status, + "reconcile_rerun": true, }) if err != nil { return 0, fmt.Errorf("marshal probe rerun summary for %s: %w", accountID, err) @@ -218,7 +241,7 @@ func (s *ReconcileService) rerunAccountProbes(ctx context.Context, items []sqlit if _, err := s.store.ProbeResults().Create(ctx, sqlite.ProbeResult{BatchItemID: item.ID, ProbeType: "account_smoke_rerun", Status: status, SummaryJSON: string(payload)}); err != nil { return 0, err } - if !probe.OK || !smokeModelSeen { + if result.HasBlockingFailure() { failures++ } } @@ -239,6 +262,21 @@ func (s *ReconcileService) rerunAccessClosure(ctx context.Context, batchID int64 return "", false, fmt.Errorf("re-check gateway access: %w", err) } if result.OK && result.HasExpectedModel { + completion, err := s.host.CheckGatewayCompletion(ctx, sub2api.GatewayCompletionCheckRequest{ + APIKey: probeAPIKey, + Model: expectedModel, + Prompt: "ping", + MaxTokens: 8, + }) + if err != nil { + return "", false, fmt.Errorf("re-check gateway completion: %w", err) + } + result.CompletionOK = completion.OK + result.CompletionStatus = completion.StatusCode + result.CompletionType = completion.ContentType + result.CompletionBody = completion.BodyPreview + } + if GatewayAccessReady(result) { status = deriveHealthyAccessStatus(latest.ClosureType) } else { status = AccessStatusBroken @@ -248,6 +286,10 @@ func (s *ReconcileService) rerunAccessClosure(ctx context.Context, batchID int64 "ok": result.OK, "has_expected_model": result.HasExpectedModel, "models": result.Models, + "completion_ok": result.CompletionOK, + "completion_status": result.CompletionStatus, + "completion_type": result.CompletionType, + "completion_preview": result.CompletionBody, "reconcile_rerun": true, }) if err != nil { @@ -302,6 +344,42 @@ func accountIDFromProbeSummary(summaryJSON string) (string, error) { return strings.TrimSpace(accountID), nil } +func (s *ReconcileService) storedResourcesForReconcile(ctx context.Context, providerID, hostID, batchID int64) ([]sqlite.ManagedResource, error) { + storedResources, err := s.store.ManagedResources().GetByBatchID(ctx, batchID) + if err != nil { + return nil, err + } + sharedResources, err := s.store.ManagedResources().ListByProviderIDAndHostID(ctx, providerID, hostID) + if err != nil { + return nil, err + } + merged := make([]sqlite.ManagedResource, 0, len(storedResources)+len(sharedResources)) + seen := make(map[string]struct{}, len(storedResources)+len(sharedResources)) + appendUnique := func(resource sqlite.ManagedResource) { + resourceType := strings.TrimSpace(resource.ResourceType) + resourceID := strings.TrimSpace(resource.HostResourceID) + if resourceType == "" || resourceID == "" { + return + } + key := resourceType + ":" + resourceID + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + merged = append(merged, resource) + } + for _, resource := range storedResources { + appendUnique(resource) + } + for _, resource := range sharedResources { + switch strings.TrimSpace(resource.ResourceType) { + case "group", "channel", "plan": + appendUnique(resource) + } + } + return merged, nil +} + func diffManagedResources(stored []sqlite.ManagedResource, snapshot sub2api.ManagedResourceSnapshot) (int, int) { live := map[string]map[string]struct{}{ "group": make(map[string]struct{}), @@ -348,3 +426,32 @@ func diffManagedResources(stored []sqlite.ManagedResource, snapshot sub2api.Mana } return missing, extra } + +func classifyHistoricalAccountNoise(currentBatchResources []sqlite.ManagedResource, snapshotAccounts []sub2api.NamedResource, accountNamePrefix string) []sub2api.NamedResource { + currentAccountIDs := make(map[string]struct{}) + for _, resource := range currentBatchResources { + if strings.TrimSpace(resource.ResourceType) != "account" { + continue + } + if id := strings.TrimSpace(resource.HostResourceID); id != "" { + currentAccountIDs[id] = struct{}{} + } + } + + prefix := strings.TrimSpace(accountNamePrefix) + staleNoise := make([]sub2api.NamedResource, 0) + for _, account := range snapshotAccounts { + id := strings.TrimSpace(account.ID) + if id == "" { + continue + } + if _, ok := currentAccountIDs[id]; ok { + continue + } + if prefix != "" && !strings.HasPrefix(strings.TrimSpace(account.Name), prefix) { + continue + } + staleNoise = append(staleNoise, sub2api.NamedResource{ID: id, Name: strings.TrimSpace(account.Name)}) + } + return staleNoise +} diff --git a/internal/provision/import_service.go b/internal/provision/import_service.go index fff1e76d..e9918b0d 100644 --- a/internal/provision/import_service.go +++ b/internal/provision/import_service.go @@ -31,6 +31,10 @@ const ( AccessStatusSelfServiceReady = "self_service_ready" AccessStatusFullyReady = "fully_ready" AccessStatusBroken = "broken" + + AccountStatusPassed = "passed" + AccountStatusWarning = "warning" + AccountStatusFailed = "failed" ) type AccessRequest struct { @@ -70,11 +74,80 @@ type AccountImportResult struct { SmokeModelSeen bool } +func (r AccountImportResult) ValidationStatus() string { + if !r.SmokeModelSeen { + return AccountStatusFailed + } + if r.Probe.OK { + return AccountStatusPassed + } + if isAdvisoryAccountProbeFailure(r.Probe) { + return AccountStatusWarning + } + return AccountStatusFailed +} + +func (r AccountImportResult) HasBlockingFailure() bool { + return r.ValidationStatus() == AccountStatusFailed +} + +func (r AccountImportResult) HasAdvisoryWarning() bool { + return r.ValidationStatus() == AccountStatusWarning +} + type hostAdapter interface { sub2api.HostAdapter CheckGatewayAccess(ctx context.Context, req sub2api.GatewayAccessCheckRequest) (sub2api.GatewayAccessResult, error) } +func GatewayAccessReady(result sub2api.GatewayAccessResult) bool { + return result.OK && result.HasExpectedModel && result.CompletionOK +} + +func isAdvisoryAccountProbeFailure(probe sub2api.ProbeResult) bool { + if probe.OK { + return false + } + + message := strings.ToLower(strings.TrimSpace(probe.Message)) + if message == "" { + return false + } + + if isTransientAccountProbeFailure(message) { + return true + } + + if !strings.Contains(message, "responses api") { + return false + } + + return strings.Contains(message, "当前测试接口仅支持") || + strings.Contains(message, "账号本身可正常使用") || + strings.Contains(message, "please directly") || + strings.Contains(message, "actual api") +} + +func isTransientAccountProbeFailure(message string) bool { + if !(strings.Contains(message, "429") || + strings.Contains(message, "rate limit") || + strings.Contains(message, "too many requests") || + strings.Contains(message, "502") || + strings.Contains(message, "503") || + strings.Contains(message, "504") || + strings.Contains(message, "bad gateway") || + strings.Contains(message, "service unavailable") || + strings.Contains(message, "timeout")) { + return false + } + + return strings.Contains(message, "api returned") || + strings.Contains(message, "rate_limit") || + strings.Contains(message, "upstream") || + strings.Contains(message, "temporar") || + strings.Contains(message, "retry") +} + type resolvedManagedResources struct { Group sub2api.GroupRef Channel sub2api.ChannelRef @@ -143,7 +216,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I } rollback.AddAccounts(accounts) for _, account := range accounts { - probe, err := s.host.TestAccount(ctx, account.ID) + probe, err := s.host.TestAccount(ctx, account.ID, req.Provider.SmokeTestModel) if err != nil { return failOrDegrade(report, req.Mode, fmt.Errorf("test account %s: %w", account.ID, err)) } @@ -157,7 +230,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I failedAccounts := 0 for _, account := range report.Accounts { - if !account.Probe.OK || !account.SmokeModelSeen { + if account.HasBlockingFailure() { failedAccounts++ } } @@ -188,7 +261,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I report.BatchStatus = BatchStatusSucceeded report.ProviderStatus = ProviderStatusActive - if failedAccounts > 0 || !gateway.OK || !gateway.HasExpectedModel { + if failedAccounts > 0 || !GatewayAccessReady(gateway) { report.BatchStatus = BatchStatusPartial report.ProviderStatus = ProviderStatusDegraded } @@ -198,7 +271,7 @@ func (s *ImportService) Import(ctx context.Context, req ImportRequest) (report I case AccessModeSelfService: report.AccessStatus = AccessStatusSelfServiceReady } - if !gateway.OK || !gateway.HasExpectedModel { + if !GatewayAccessReady(gateway) { report.AccessStatus = AccessStatusBroken } return report, nil diff --git a/internal/provision/import_service_test.go b/internal/provision/import_service_test.go index 7a146148..c4809cde 100644 --- a/internal/provision/import_service_test.go +++ b/internal/provision/import_service_test.go @@ -65,6 +65,9 @@ func TestImportServiceImportSubscriptionFlow(t *testing.T) { if host.gatewayProbe.ExpectedModel != "deepseek-chat" { t.Fatalf("gateway probe model = %q, want %q", host.gatewayProbe.ExpectedModel, "deepseek-chat") } + if host.testedModels["account_1"] != "deepseek-chat" || host.testedModels["account_2"] != "deepseek-chat" { + t.Fatalf("testedModels = %#v, want deepseek-chat for all created accounts", host.testedModels) + } } func TestImportServiceStrictModeFailsWhenAnyAccountProbeFails(t *testing.T) { @@ -111,6 +114,136 @@ func TestImportServiceRejectsUnknownMode(t *testing.T) { } } +func TestImportServiceMarksBrokenWhenCompletionSmokeFails(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + completionResult: sub2api.GatewayCompletionResult{OK: false, StatusCode: 502, ContentType: "application/json", BodyPreview: `{"error":"upstream_error"}`}, + } + + 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 != BatchStatusPartial { + t.Fatalf("BatchStatus = %q, want %q", report.BatchStatus, BatchStatusPartial) + } + if report.ProviderStatus != ProviderStatusDegraded { + t.Fatalf("ProviderStatus = %q, want %q", report.ProviderStatus, ProviderStatusDegraded) + } + if report.AccessStatus != AccessStatusBroken { + t.Fatalf("AccessStatus = %q, want %q", report.AccessStatus, AccessStatusBroken) + } +} + +func TestImportServiceTreatsResponsesOnlyProbeFailureAsAdvisoryWhenGatewaySucceeds(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "minimax-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": { + OK: false, + Status: "failed", + Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。", + }, + }, + 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: "text/event-stream", + }, + } + + 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 len(report.Accounts) != 1 { + t.Fatalf("Accounts len = %d, want 1", len(report.Accounts)) + } + if got := report.Accounts[0].ValidationStatus(); got != AccountStatusWarning { + t.Fatalf("ValidationStatus = %q, want %q", got, AccountStatusWarning) + } +} + +func TestImportServiceTreatsTransientProbeFailureAsAdvisoryWhenGatewaySucceeds(t *testing.T) { + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": { + OK: false, + Status: "failed", + Message: "API returned 429: {\"error\":{\"message\":\"Rate limited (429); user=1997/2000 model=49/50; daily_exhausted=False\",\"type\":\"rate_limit_error\",\"code\":\"rate_limit_exceeded\"}}", + }, + }, + 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 TestImportServiceStrictModeRollsBackCreatedResources(t *testing.T) { host := &fakeHostAdapter{ batchAccounts: []sub2api.AccountRef{{ID: "account_1"}, {ID: "account_2"}}, @@ -325,7 +458,7 @@ func TestImportDeletesExistingProviderAccountsBeforeGatewayClosure(t *testing.T) if !reflect.DeepEqual(host.deletedResources, wantDeleted) { t.Fatalf("deleted resources = %#v, want %#v", host.deletedResources, wantDeleted) } - if !reflect.DeepEqual(host.callSequence, []string{"deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway"}) { + if !reflect.DeepEqual(host.callSequence, []string{"deleteAccount:account_old_2", "deleteAccount:account_old_1", "gateway", "completion"}) { t.Fatalf("call sequence = %#v, want stale-account cleanup before gateway probe", host.callSequence) } } @@ -374,6 +507,7 @@ type fakeHostAdapter struct { hostVersion string assignedSubscriptions []sub2api.AssignSubscriptionRequest gatewayProbe sub2api.GatewayAccessCheckRequest + completionProbe sub2api.GatewayCompletionCheckRequest deletedResources []string managedSnapshot sub2api.ManagedResourceSnapshot listManagedReq sub2api.ListManagedResourcesRequest @@ -386,6 +520,9 @@ type fakeHostAdapter struct { updateChannelID string updateChannelReq sub2api.CreateChannelRequest callSequence []string + completionResult sub2api.GatewayCompletionResult + completionErr error + testedModels map[string]string } func (f *fakeHostAdapter) GetHostVersion(context.Context) (string, error) { @@ -444,7 +581,11 @@ func (f *fakeHostAdapter) DeleteAccount(_ context.Context, accountID string) err f.deletedResources = append(f.deletedResources, "account:"+accountID) return nil } -func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID string) (sub2api.ProbeResult, error) { +func (f *fakeHostAdapter) TestAccount(_ context.Context, accountID, modelID string) (sub2api.ProbeResult, error) { + if f.testedModels == nil { + f.testedModels = map[string]string{} + } + f.testedModels[accountID] = modelID result, ok := f.testResults[accountID] if !ok { return sub2api.ProbeResult{}, fmt.Errorf("missing test result for %s", accountID) @@ -476,6 +617,17 @@ func (f *fakeHostAdapter) CheckGatewayAccess(_ context.Context, req sub2api.Gate } return f.gatewayResult, nil } +func (f *fakeHostAdapter) CheckGatewayCompletion(_ context.Context, req sub2api.GatewayCompletionCheckRequest) (sub2api.GatewayCompletionResult, error) { + f.callSequence = append(f.callSequence, "completion") + f.completionProbe = req + if f.completionErr != nil { + return sub2api.GatewayCompletionResult{}, f.completionErr + } + if f.completionResult.StatusCode == 0 && !f.completionResult.OK { + return sub2api.GatewayCompletionResult{OK: true, StatusCode: 200, ContentType: "application/json"}, nil + } + return f.completionResult, nil +} func (f *fakeHostAdapter) ListManagedResources(_ context.Context, req sub2api.ListManagedResourcesRequest) (sub2api.ManagedResourceSnapshot, error) { f.listManagedReq = req return sub2api.ManagedResourceSnapshot{ diff --git a/internal/provision/reconcile_service_test.go b/internal/provision/reconcile_service_test.go index 87b939eb..cc5b7e14 100644 --- a/internal/provision/reconcile_service_test.go +++ b/internal/provision/reconcile_service_test.go @@ -105,6 +105,63 @@ func TestReconcileServiceReturnsDegradedWhenProbeRerunFails(t *testing.T) { } } +func TestReconcileServiceIgnoresAdvisoryProbeFailureWhenModelsAndGatewayAreHealthy(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": { + OK: false, + Status: "failed", + Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。", + }, + "account_2": { + OK: false, + Status: "failed", + Message: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。", + }, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + "account_2": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{ + OK: true, + StatusCode: 200, + HasExpectedModel: true, + Models: []string{"deepseek-chat"}, + CompletionOK: true, + CompletionStatus: 200, + }, + } + + seedRuntimeImportForReconcile(t, store, host) + host.managedSnapshot = sub2api.ManagedResourceSnapshot{ + Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}}, + Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}}, + Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, + } + + result, err := NewReconcileService(store, host).Reconcile(context.Background(), ReconcileRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + AccessProbeAPIKey: "user-key", + Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, + Provider: sampleProviderManifest(), + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.Status != "active" { + t.Fatalf("Status = %q, want active", result.Status) + } + if result.ProbeFailureCount != 0 { + t.Fatalf("ProbeFailureCount = %d, want 0", result.ProbeFailureCount) + } +} + func TestReconcileServiceReturnsDriftedWhenManagedResourceMissing(t *testing.T) { store := openProvisionTestStore(t) defer closeProvisionTestStore(t, store) @@ -232,6 +289,181 @@ func TestReconcileServicePassesAccountNamePrefixToManagedResourceSnapshot(t *tes } } +func TestReconcileServiceIgnoresSharedResourceReuseAcrossBatches(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + host := &fakeHostAdapter{ + batchAccounts: []sub2api.AccountRef{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}}, + testResults: map[string]sub2api.ProbeResult{ + "account_1": {OK: true, Status: "passed"}, + "account_2": {OK: true, Status: "passed"}, + "account_3": {OK: true, Status: "passed"}, + "account_4": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_1": {{ID: "deepseek-chat"}}, + "account_2": {{ID: "deepseek-chat"}}, + "account_3": {{ID: "deepseek-chat"}}, + "account_4": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + seedRuntimeImportForReconcile(t, store, host) + ctx := context.Background() + packRow, err := store.Packs().GetByPackID(ctx, "openai-cn-pack") + if err != nil { + t.Fatalf("Packs().GetByPackID() error = %v", err) + } + providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, sampleProviderManifest().ProviderID) + if err != nil { + t.Fatalf("Providers().GetByPackIDAndProviderID() error = %v", err) + } + hostRow, err := store.Hosts().GetByHostID(ctx, "host-1") + if err != nil { + t.Fatalf("Hosts().GetByHostID() error = %v", err) + } + latestBatchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostRow.ID, PackID: packRow.ID, ProviderID: providerRow.ID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create() error = %v", err) + } + if _, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{BatchID: latestBatchID, KeyFingerprint: "key-3", AccountStatus: "passed", ProbeSummaryJSON: `{"account_id":"account_3"}`}); err != nil { + t.Fatalf("ImportBatchItems().Create() error = %v", err) + } + if _, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{BatchID: latestBatchID, KeyFingerprint: "key-4", AccountStatus: "passed", ProbeSummaryJSON: `{"account_id":"account_4"}`}); err != nil { + t.Fatalf("ImportBatchItems().Create() error = %v", err) + } + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: latestBatchID, HostID: hostRow.ID, ResourceType: "account", HostResourceID: "account_3", ResourceName: "deepseek-03"}); err != nil { + t.Fatalf("ManagedResources().Create(account_3) error = %v", err) + } + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: latestBatchID, HostID: hostRow.ID, ResourceType: "account", HostResourceID: "account_4", ResourceName: "deepseek-04"}); err != nil { + t.Fatalf("ManagedResources().Create(account_4) error = %v", err) + } + if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: latestBatchID, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil { + t.Fatalf("AccessClosures().Create() error = %v", err) + } + + host.managedSnapshot = sub2api.ManagedResourceSnapshot{ + Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}}, + Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}}, + Accounts: []sub2api.NamedResource{{ID: "account_3", Name: "deepseek-03"}, {ID: "account_4", Name: "deepseek-04"}}, + } + + result, err := NewReconcileService(store, host).Reconcile(ctx, ReconcileRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + AccessProbeAPIKey: "user-key", + Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, + Provider: sampleProviderManifest(), + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.BatchID != latestBatchID { + t.Fatalf("BatchID = %d, want latest batch %d", result.BatchID, latestBatchID) + } + if result.Status != "active" { + t.Fatalf("Status = %q, want active when only shared resources are reused", result.Status) + } + if result.MissingCount != 0 || result.ExtraCount != 0 { + t.Fatalf("Managed resource diff = (%d, %d), want (0, 0)", result.MissingCount, result.ExtraCount) + } +} + +func TestReconcileServiceClassifiesHistoricalAccountsAsStaleNoise(t *testing.T) { + store := openProvisionTestStore(t) + defer closeProvisionTestStore(t, store) + + host := &fakeHostAdapter{ + testResults: map[string]sub2api.ProbeResult{ + "account_3": {OK: true, Status: "passed"}, + "account_4": {OK: true, Status: "passed"}, + }, + models: map[string][]sub2api.AccountModel{ + "account_3": {{ID: "deepseek-chat"}}, + "account_4": {{ID: "deepseek-chat"}}, + }, + gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}}, + } + + seedRuntimeImportForReconcile(t, store, host) + ctx := context.Background() + packRow, err := store.Packs().GetByPackID(ctx, "openai-cn-pack") + if err != nil { + t.Fatalf("Packs().GetByPackID() error = %v", err) + } + providerRow, err := store.Providers().GetByPackIDAndProviderID(ctx, packRow.ID, sampleProviderManifest().ProviderID) + if err != nil { + t.Fatalf("Providers().GetByPackIDAndProviderID() error = %v", err) + } + hostRow, err := store.Hosts().GetByHostID(ctx, "host-1") + if err != nil { + t.Fatalf("Hosts().GetByHostID() error = %v", err) + } + latestBatchID, err := store.ImportBatches().Create(ctx, sqlite.ImportBatch{HostID: hostRow.ID, PackID: packRow.ID, ProviderID: providerRow.ID, Mode: ImportModePartial, BatchStatus: BatchStatusSucceeded, AccessStatus: AccessStatusSelfServiceReady}) + if err != nil { + t.Fatalf("ImportBatches().Create() error = %v", err) + } + for _, item := range []struct { + keyFingerprint string + accountID string + accountName string + }{ + {keyFingerprint: "key-3", accountID: "account_3", accountName: "deepseek-03"}, + {keyFingerprint: "key-4", accountID: "account_4", accountName: "deepseek-04"}, + } { + if _, err := store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{BatchID: latestBatchID, KeyFingerprint: item.keyFingerprint, AccountStatus: "passed", ProbeSummaryJSON: `{"account_id":"` + item.accountID + `"}`}); err != nil { + t.Fatalf("ImportBatchItems().Create(%s) error = %v", item.accountID, err) + } + if _, err := store.ManagedResources().Create(ctx, sqlite.ManagedResource{BatchID: latestBatchID, HostID: hostRow.ID, ResourceType: "account", HostResourceID: item.accountID, ResourceName: item.accountName}); err != nil { + t.Fatalf("ManagedResources().Create(%s) error = %v", item.accountID, err) + } + } + if _, err := store.AccessClosures().Create(ctx, sqlite.AccessClosureRecord{BatchID: latestBatchID, ClosureType: AccessModeSelfService, Status: AccessStatusSelfServiceReady, DetailsJSON: "{}"}); err != nil { + t.Fatalf("AccessClosures().Create() error = %v", err) + } + + host.managedSnapshot = sub2api.ManagedResourceSnapshot{ + Groups: []sub2api.NamedResource{{ID: "group_1", Name: "DeepSeek 默认分组-self-service"}}, + Channels: []sub2api.NamedResource{{ID: "channel_1", Name: "DeepSeek 默认渠道-self-service"}}, + Accounts: []sub2api.NamedResource{{ID: "account_1", Name: "deepseek-01"}, {ID: "account_2", Name: "deepseek-02"}, {ID: "account_3", Name: "deepseek-03"}, {ID: "account_4", Name: "deepseek-04"}}, + } + + result, err := NewReconcileService(store, host).Reconcile(ctx, ReconcileRequest{ + HostID: "host-1", + HostBaseURL: "https://sub2api.example.com", + AccessProbeAPIKey: "user-key", + Pack: pack.LoadedPack{Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"}}, + Provider: sampleProviderManifest(), + }) + if err != nil { + t.Fatalf("Reconcile() error = %v", err) + } + if result.BatchID != latestBatchID { + t.Fatalf("BatchID = %d, want latest batch %d", result.BatchID, latestBatchID) + } + if result.Status != "active" { + t.Fatalf("Status = %q, want active when only historical same-prefix accounts remain (missing=%d extra=%d stale=%d summary=%#v)", result.Status, result.MissingCount, result.ExtraCount, result.StaleNoiseCount, result.Summary) + } + if result.ExtraCount != 0 { + t.Fatalf("ExtraCount = %d, want 0 after stale-noise classification", result.ExtraCount) + } + if result.StaleNoiseCount != 2 { + t.Fatalf("StaleNoiseCount = %d, want 2", result.StaleNoiseCount) + } + if got, ok := result.Summary["stale_noise_count"].(int); !ok || got != 2 { + t.Fatalf("Summary[stale_noise_count] = %#v, want int(2)", result.Summary["stale_noise_count"]) + } + accounts, ok := result.Summary["stale_noise_accounts"].([]sub2api.NamedResource) + if !ok { + t.Fatalf("Summary[stale_noise_accounts] type = %T, want []sub2api.NamedResource", result.Summary["stale_noise_accounts"]) + } + if len(accounts) != 2 || accounts[0].ID != "account_1" || accounts[1].ID != "account_2" { + t.Fatalf("Summary[stale_noise_accounts] = %#v, want historical accounts 1 and 2", accounts) + } +} + func TestReconcileServiceRejectsRolledBackLatestBatch(t *testing.T) { store := openProvisionTestStore(t) defer closeProvisionTestStore(t, store) diff --git a/internal/provision/runtime_import_service.go b/internal/provision/runtime_import_service.go index b40382f8..3194b5a2 100644 --- a/internal/provision/runtime_import_service.go +++ b/internal/provision/runtime_import_service.go @@ -169,13 +169,16 @@ func (s *RuntimeImportService) ensureProvider(ctx context.Context, packID int64, func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID, hostID int64, accessMode string, report ImportReport, includeManagedResources bool) error { for i, account := range report.Accounts { + validationStatus := account.ValidationStatus() payload, err := json.Marshal(map[string]any{ - "account_id": account.Ref.ID, - "probe_ok": account.Probe.OK, - "probe_status": account.Probe.Status, - "probe_message": account.Probe.Message, - "models": account.Models, - "smoke_model_seen": account.SmokeModelSeen, + "account_id": account.Ref.ID, + "probe_ok": account.Probe.OK, + "probe_status": account.Probe.Status, + "probe_message": account.Probe.Message, + "models": account.Models, + "smoke_model_seen": account.SmokeModelSeen, + "probe_advisory": account.HasAdvisoryWarning(), + "validation_status": validationStatus, }) if err != nil { return fmt.Errorf("marshal account probe summary: %w", err) @@ -183,7 +186,7 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc itemID, err := s.store.ImportBatchItems().Create(ctx, sqlite.ImportBatchItem{ BatchID: batchID, KeyFingerprint: fingerprintKey(report.AcceptedKeys, i), - AccountStatus: firstNonEmpty(account.Probe.Status, "unknown"), + AccountStatus: validationStatus, ProbeSummaryJSON: string(payload), }) if err != nil { @@ -192,7 +195,7 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc if _, err := s.store.ProbeResults().Create(ctx, sqlite.ProbeResult{ BatchItemID: itemID, ProbeType: "account_smoke", - Status: firstNonEmpty(account.Probe.Status, "unknown"), + Status: validationStatus, SummaryJSON: string(payload), }); err != nil { return err @@ -223,6 +226,10 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc "ok": report.Gateway.OK, "has_expected_model": report.Gateway.HasExpectedModel, "models": report.Gateway.Models, + "completion_ok": report.Gateway.CompletionOK, + "completion_status": report.Gateway.CompletionStatus, + "completion_type": report.Gateway.CompletionType, + "completion_preview": report.Gateway.CompletionBody, }) if err != nil { return fmt.Errorf("marshal gateway access summary: %w", err) diff --git a/internal/provision/runtime_import_service_test.go b/internal/provision/runtime_import_service_test.go index 9aa8ec0b..c1151fde 100644 --- a/internal/provision/runtime_import_service_test.go +++ b/internal/provision/runtime_import_service_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "path/filepath" + "strings" "testing" _ "modernc.org/sqlite" @@ -171,6 +172,70 @@ func TestRuntimeImportServicePersistsFailedBatchAfterStrictRollback(t *testing.T } } +func TestRuntimeImportServicePersistsWarningAccountStatusForAdvisoryProbeFailure(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: "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。", + }, + }, + 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) diff --git a/scripts/import_remote43_provider.sh b/scripts/import_remote43_provider.sh index 324f4d80..c98fd4bb 100755 --- a/scripts/import_remote43_provider.sh +++ b/scripts/import_remote43_provider.sh @@ -41,6 +41,20 @@ if [[ -z "$upstream_key" ]]; then exit 2 fi +upstream_base_url="$(python3 - "$PACK_PATH" "$provider_id" <<'PY' +import json, pathlib, sys +pack_path = pathlib.Path(sys.argv[1]) +provider_id = sys.argv[2] +provider_file = pack_path / "providers" / f"{provider_id}.json" +provider = json.loads(provider_file.read_text(encoding='utf-8')) +print(str(provider.get("base_url", "")).strip()) +PY +)" +if [[ -z "$upstream_base_url" ]]; then + echo "missing provider base_url for $provider_id in $PACK_PATH" >&2 + exit 2 +fi + ssh_cmd() { local cmd="$1" ssh -i "$KEY" -o StrictHostKeyChecking=no "$REMOTE" "$cmd" @@ -454,6 +468,14 @@ ssh_cmd "curl -sS -D /tmp/chat_headers.txt -o /tmp/chat_body.json -H 'Authorizat ssh_cmd "cat /tmp/chat_headers.txt" > "$ART/11-chat.headers.txt" ssh_cmd "cat /tmp/chat_body.json" > "$ART/12-chat.body.json" +ssh_cmd "curl -sS -D /tmp/upstream_models_headers.txt -o /tmp/upstream_models_body.json -H 'Authorization: Bearer $upstream_key' ${upstream_base_url%/}/models" +ssh_cmd "cat /tmp/upstream_models_headers.txt" > "$ART/17-upstream-models.headers.txt" +ssh_cmd "cat /tmp/upstream_models_body.json" > "$ART/18-upstream-models.body.json" + +ssh_cmd "curl -sS -D /tmp/upstream_chat_headers.txt -o /tmp/upstream_chat_body.txt -H 'Authorization: Bearer $upstream_key' -H 'Content-Type: application/json' ${upstream_base_url%/}/chat/completions -d $(printf %q "$probe_payload")" +ssh_cmd "cat /tmp/upstream_chat_headers.txt" > "$ART/19-upstream-chat.headers.txt" +ssh_cmd "cat /tmp/upstream_chat_body.txt" > "$ART/20-upstream-chat.body.txt" + crm_curl_json GET "/api/providers/$provider_id/status" > "$ART/13-provider-status.json" crm_curl_json GET "/api/providers/$provider_id/access/status" > "$ART/14-access-status.json" preview_payload="$(python3 - "$provider_id" <<'PY' @@ -472,17 +494,67 @@ provider_id=sys.argv[2] batch_id=int(sys.argv[3]) subscription_group_id=sys.argv[4] expected_model=sys.argv[5] + +def normalize_model_id(model_id: str) -> str: + value = str(model_id or '').strip().lower() + if not value: + return '' + if '/' in value: + value = value.split('/')[-1] + return value + +def has_expected_model(models, expected: str) -> bool: + normalized_expected = normalize_model_id(expected) + if not normalized_expected: + return False + return any(normalize_model_id(model_id) == normalized_expected for model_id in models) + +def status_from_headers(path: pathlib.Path) -> int: + if not path.exists(): + return 0 + for line in path.read_text(encoding='utf-8').splitlines(): + parts = line.strip().split() + if len(parts) >= 2 and parts[0].startswith('HTTP/'): + try: + return int(parts[1]) + except ValueError: + return 0 + return 0 + +def load_json(path: pathlib.Path): + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception: + return {} + import_obj=json.loads((art/'03-import.body.json').read_text()) -models_obj=json.loads((art/'10-models.body.json').read_text()) -access_status=json.loads((art/'14-access-status.json').read_text()) -preview=json.loads((art/'15-access-preview.json').read_text()) +models_obj=load_json(art/'10-models.body.json') +access_status=load_json(art/'14-access-status.json') +preview=load_json(art/'15-access-preview.json') models_headers=(art/'09-models.headers.txt').read_text() chat_headers=(art/'11-chat.headers.txt').read_text() +upstream_models_obj=load_json(art/'18-upstream-models.body.json') +upstream_chat_headers=(art/'19-upstream-chat.headers.txt') +upstream_chat_body=(art/'20-upstream-chat.body.txt').read_text(encoding='utf-8') models=[] for item in models_obj.get('data') or []: model_id = item.get('id') if isinstance(model_id, str) and model_id: models.append(model_id) +upstream_models=[] +for item in upstream_models_obj.get('data') or []: + model_id = item.get('id') + if isinstance(model_id, str) and model_id: + upstream_models.append(model_id) +host_chat_status = status_from_headers(art/'11-chat.headers.txt') +upstream_chat_status = status_from_headers(upstream_chat_headers) +classification = 'unknown' +direct_has_expected_model = has_expected_model(models, expected_model) +upstream_has_expected_model = has_expected_model(upstream_models, expected_model) +if direct_has_expected_model and host_chat_status >= 500 and upstream_chat_status == 200: + classification = 'host_compatibility_gap' +elif direct_has_expected_model and upstream_chat_status == 403 and 'insufficient_user_quota' in upstream_chat_body: + classification = 'upstream_key_quota_issue' summary={ 'artifact_dir': str(art), 'provider_id': provider_id, @@ -491,14 +563,21 @@ summary={ 'access_status_from_import': import_obj.get('access_status'), 'provider_status_from_import': import_obj.get('provider_status'), 'direct_models_http200': '200 OK' in models_headers, - 'direct_models_has_expected_model': expected_model in models, + 'direct_models_has_expected_model': direct_has_expected_model, 'direct_models': models, 'direct_chat_http200': '200 OK' in chat_headers, + 'direct_chat_status': host_chat_status, + 'upstream_models': upstream_models, + 'upstream_models_has_expected_model': upstream_has_expected_model, + 'upstream_chat_status': upstream_chat_status, + 'completion_classification': classification, 'latest_access_status': access_status.get('latest_access_status') or access_status.get('batch_access_status'), 'preview_available': preview.get('available'), 'accepted_keys_count': import_obj.get('accepted_keys_count'), 'subscription_group_id': subscription_group_id, 'import_group_id': (import_obj.get('group') or {}).get('id'), } -print(json.dumps(summary, ensure_ascii=False)) +summary_json = json.dumps(summary, ensure_ascii=False) +(art / '21-summary.json').write_text(summary_json, encoding='utf-8') +print(summary_json) PY diff --git a/scripts/test_real_host_scripts.sh b/scripts/test_real_host_scripts.sh index f8340f35..6516ffc4 100644 --- a/scripts/test_real_host_scripts.sh +++ b/scripts/test_real_host_scripts.sh @@ -243,14 +243,17 @@ EOF } run_test_import_remote43_provider_subscription_prep() { - local tmpdir fakebin artifact_dir ssh_log psql_sql + local tmpdir fakebin artifact_dir ssh_log psql_sql pack_dir tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' RETURN fakebin="$tmpdir/bin" artifact_dir="$tmpdir/artifacts" ssh_log="$artifact_dir/ssh-log.txt" psql_sql="$artifact_dir/prep.sql" + pack_dir="$tmpdir/pack" mkdir -p "$fakebin" + mkdir -p "$pack_dir/providers" + printf '%s\n' '{"provider_id":"deepseek","base_url":"https://upstream.example.com/v1"}' > "$pack_dir/providers/deepseek.json" cat > "$fakebin/curl" <<'EOF' #!/usr/bin/env bash @@ -338,7 +341,7 @@ if [[ "$cmd" == *'***'* ]]; then echo "unexpected redacted auth placeholder in ssh command: $cmd" >&2 exit 1 fi -case "$cmd" in + case "$cmd" in *"/api/v1/auth/login"*) printf '%s\n' 'host-bearer-token' ;; @@ -390,6 +393,26 @@ case "$cmd" in "cat /tmp/chat_body.json") cat /tmp/chat_body.json ;; + *"curl -sS -D /tmp/upstream_models_headers.txt"*) + printf '%s\n' 'HTTP/1.1 200 OK' > /tmp/upstream_models_headers.txt + printf '%s\n' '{"data":[{"id":"openai/gpt-4"},{"id":"openai/gpt-4.1"}]}' > /tmp/upstream_models_body.json + ;; + "cat /tmp/upstream_models_headers.txt") + cat /tmp/upstream_models_headers.txt + ;; + "cat /tmp/upstream_models_body.json") + cat /tmp/upstream_models_body.json + ;; + *"curl -sS -D /tmp/upstream_chat_headers.txt"*) + printf '%s\n' 'HTTP/1.1 200 OK' > /tmp/upstream_chat_headers.txt + printf '%s\n' '{"choices":[{"message":{"content":"upstream-pong"}}]}' > /tmp/upstream_chat_body.txt + ;; + "cat /tmp/upstream_chat_headers.txt") + cat /tmp/upstream_chat_headers.txt + ;; + "cat /tmp/upstream_chat_body.txt") + cat /tmp/upstream_chat_body.txt + ;; *"/api/providers/deepseek/status"*) printf '%s\n' '{"status":"ready"}' ;; @@ -451,7 +474,7 @@ EOF CRM_HOST_BASE="http://127.0.0.1:18093" \ ROOT="$artifact_dir/root" \ ART="$artifact_dir/run" \ - PACK_PATH="/tmp/openai-pack" \ + PACK_PATH="$pack_dir" \ REMOTE_PG_CONTAINER="fresh-pg" \ REMOTE_REDIS_CONTAINER="fresh-redis" \ UPSTREAM_KEY="upstream-test-key" \ @@ -474,14 +497,21 @@ EOF assert_contains "$invalidation_log" "auth_cache_key=apikey:auth:" assert_contains "$invalidation_log" "balance_cache_key=billing:balance:84" assert_contains "$invalidation_log" "subscription_cache_key=billing:sub:84:7" - local subscription_state models_body chat_body + local subscription_state models_body chat_body upstream_models upstream_chat summary_json subscription_state="$(cat "$artifact_dir/run/08-subscription-group-state.json")" assert_contains "$subscription_state" '"group_id":7' assert_contains "$subscription_state" '"status":"active"' models_body="$(cat "$artifact_dir/run/10-models.body.json")" chat_body="$(cat "$artifact_dir/run/12-chat.body.json")" + upstream_models="$(cat "$artifact_dir/run/18-upstream-models.body.json")" + upstream_chat="$(cat "$artifact_dir/run/20-upstream-chat.body.txt")" + summary_json="$(cat "$artifact_dir/run/21-summary.json" 2>/dev/null || true)" assert_contains "$models_body" '"id":"gpt-4"' assert_contains "$chat_body" '"content":"pong"' + assert_contains "$upstream_models" '"id":"openai/gpt-4"' + assert_contains "$upstream_chat" '"content":"upstream-pong"' + assert_contains "$summary_json" '"upstream_models_has_expected_model": true' + assert_contains "$summary_json" '"completion_classification": "unknown"' [[ -s "$ssh_log" ]] || fail "ssh log was empty" } diff --git a/tests/integration/host_stub_test.go b/tests/integration/host_stub_test.go index 7ef64fdf..a877337b 100644 --- a/tests/integration/host_stub_test.go +++ b/tests/integration/host_stub_test.go @@ -149,7 +149,7 @@ func TestSub2APIHostAdapterTestAccountParsesSSE(t *testing.T) { t.Fatalf("NewClient() error = %v", err) } - result, err := client.TestAccount(context.Background(), "account_1") + result, err := client.TestAccount(context.Background(), "account_1", "deepseek-chat") if err != nil { t.Fatalf("TestAccount() error = %v", err) } @@ -591,7 +591,7 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server }) }) mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("x-api-key"); got != gatewayExpectedKey { + if got := r.Header.Get("Authorization"); got != "Bearer "+gatewayExpectedKey { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) return @@ -600,6 +600,26 @@ func newSub2APIStubServer(t *testing.T, cfg sub2APIStubConfig) *httptest.Server "data": gatewayModels, }) }) + mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+gatewayExpectedKey { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + return + } + writeJSON(t, w, http.StatusOK, map[string]any{ + "id": "chatcmpl_stub", + "object": "chat.completion", + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "pong", + }, + }, + }, + }) + }) return httptest.NewServer(mux) }