Clarify subscription probe key semantics
This commit is contained in:
@@ -195,6 +195,11 @@
|
||||
- 已补充重复导入自动复用策略:按 `provider_id + api_key_fingerprint + canonical_model_family` 判断 `reused / patch_only / replace`
|
||||
- 已补充同模型别名归一化契约:例如 `kimi 2.6 / kimi-2.6 / kimi-k2.6` 可归并到同一模型家族并快速复用
|
||||
- 已补充多账号重复导入与弃用账号再启用策略:active 账号提示“重复已启用”,disabled/deprecated 账号显示原状态并走 `reactivated` 快速启用路径
|
||||
- 已修正 access closure artifact 的 probe key 语义漂移:`subscription` 现在持久化 `requested_probe_api_key`、`effective_probe_key_source`、`effective_probe_key_fingerprint`,不再把外部 raw key 伪装成 `probe_api_key`;`probe_api_key` 仅继续用于 `self_service` 向后兼容
|
||||
- 最新干净本地 fresh-host 验收 `artifacts/real-host-acceptance/20260523_local_clean_minimax_subscription_probe_semantics` 已验证:
|
||||
- `subscription` closure 会正确区分 `requested_probe_api_key` 与 `managed_subscription` 实际 probe 来源
|
||||
- 同一轮 raw key 直打宿主仍返回 `403 not assigned to any group`
|
||||
- provider completion 仍受 MiniMax 官方 upstream `429 rate_limit_error` 影响,但这已不再会被 artifact 误读成 raw key 可用
|
||||
|
||||
**本轮实现状态(T1 ~ T13)**:
|
||||
- [x] `internal/batch` canonical types / reuse policy / service / confirmation / validation / projection 已落地
|
||||
|
||||
@@ -31,6 +31,17 @@
|
||||
- closure 最终用于宿主 `/v1/models` 探测的,不是外部传入的原始 `access_api_key`
|
||||
- 真正使用的是 CRM 在宿主侧创建/查找出来的 managed key(`sk-relay-*` 风格)
|
||||
- 因此 subscription 验收如果直接拿调用方原始 probe key 去打 `/v1/models`,出现 `403 not assigned to any group` 并不代表 CRM 主链路失败,而是 probe key 用错了
|
||||
- latest-head 当前实现已把 artifact 语义拆开:
|
||||
- `requested_probe_api_key` 记录调用方传入原始 key
|
||||
- `effective_probe_key_source=managed_subscription` 记录实际 gateway probe 来源
|
||||
- `effective_probe_key_fingerprint` 记录实际 probe key 指纹
|
||||
- `probe_api_key` 只继续保留给 `self_service`,不再在 `subscription` closure 里复用
|
||||
- 2026-05-23 的干净本地 fresh-host 验收 `artifacts/real-host-acceptance/20260523_local_clean_minimax_subscription_probe_semantics` 已再次证明这层语义修复生效:
|
||||
- closure 里 `requested_probe_api_key=sk-raw-probe-20260523b`
|
||||
- `effective_probe_key_source=managed_subscription`
|
||||
- 不再出现 legacy `probe_api_key`
|
||||
- 同一轮 raw key 直打宿主 `/v1/models` 与 `/v1/chat/completions` 仍都是 `403 permission_error`
|
||||
- 这轮 provider 最终仍是 `completion_status=429`,说明剩余阻断是 MiniMax 官方 upstream rate limit,不是 probe key 语义再次混淆
|
||||
|
||||
4. self_service 场景的 gateway probe 认证语义已经确认
|
||||
- 真实宿主的普通用户 gateway key 访问 `/v1/models` / `/v1/chat/completions` 时,使用的是 `Authorization: Bearer <gateway-key>`
|
||||
|
||||
@@ -162,6 +162,10 @@
|
||||
2. subscription 场景的 gateway probe 语义必须保持:
|
||||
- 最终 probe key 是宿主 managed key
|
||||
- 不是外部原始 `access_api_key`
|
||||
- closure artifact 必须把“请求传入的 key”和“实际探测使用的 key”分开表达:
|
||||
- `requested_probe_api_key` = 调用方传入原始 key
|
||||
- `effective_probe_key_source=managed_subscription` = 实际 gateway probe 走宿主 managed key
|
||||
- `probe_api_key` 仅继续保留给 `self_service` 向后兼容,不再用于 `subscription`
|
||||
|
||||
3. 任何 live 结论都必须先确认:
|
||||
- 在线 CRM 进程启动时间
|
||||
@@ -205,4 +209,5 @@
|
||||
- ❌ 把历史 PASS artifact 当当前 latest-head 真相
|
||||
- ❌ 把 `/v1/models` 通过当成 completion 已通过
|
||||
- ❌ 把 subscription 场景原始 `access_api_key` 当成最终 probe key
|
||||
- ❌ 把 `subscription` closure 里的 `requested_probe_api_key` 误读成实际 gateway probe key
|
||||
- ❌ 把 harness 参数错误(`PACK_PATH`、容器目标、probe auth)当成产品源码失败
|
||||
|
||||
@@ -70,6 +70,12 @@ func TestServiceCloseAssignsSubscriptionsAndProbesGateway(t *testing.T) {
|
||||
if !result.CompletionOK {
|
||||
t.Fatalf("completion result = %+v, want success", result)
|
||||
}
|
||||
if result.EffectiveProbeAPIKey != "managed-user-key" {
|
||||
t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey)
|
||||
}
|
||||
if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription {
|
||||
t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testing.T) {
|
||||
@@ -81,7 +87,7 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi
|
||||
},
|
||||
}
|
||||
service := NewService(host)
|
||||
_, err := service.Close(context.Background(), ClosureRequest{
|
||||
result, err := service.Close(context.Background(), ClosureRequest{
|
||||
Mode: "subscription",
|
||||
ProbeAPIKey: "caller-supplied-key",
|
||||
GroupID: "group-1",
|
||||
@@ -94,6 +100,12 @@ func TestServiceCloseSubscriptionManagedKeyOverridesExplicitProbeAPIKey(t *testi
|
||||
if host.gatewayProbe.APIKey != "managed-user-key" {
|
||||
t.Fatalf("gateway probe api key = %q, want managed-user-key override", host.gatewayProbe.APIKey)
|
||||
}
|
||||
if result.EffectiveProbeAPIKey != "managed-user-key" {
|
||||
t.Fatalf("effective probe api key = %q, want managed-user-key", result.EffectiveProbeAPIKey)
|
||||
}
|
||||
if result.EffectiveProbeKeySource != ProbeKeySourceManagedSubscription {
|
||||
t.Fatalf("effective probe key source = %q, want %q", result.EffectiveProbeKeySource, ProbeKeySourceManagedSubscription)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseReturnsSubscriptionErrorBeforeGatewayProbe(t *testing.T) {
|
||||
|
||||
@@ -10,19 +10,21 @@ import (
|
||||
)
|
||||
|
||||
func (s *Service) verifyGatewayClosure(ctx context.Context, req ClosureRequest, plan closurePlan) (sub2api.GatewayAccessResult, error) {
|
||||
if plan.probeAPIKey == "" {
|
||||
if plan.effectiveProbeAPIKey == "" {
|
||||
return sub2api.GatewayAccessResult{}, fmt.Errorf("access probe api key is required to verify gateway closure")
|
||||
}
|
||||
result, err := s.host.CheckGatewayAccess(ctx, sub2api.GatewayAccessCheckRequest{
|
||||
APIKey: plan.probeAPIKey,
|
||||
APIKey: plan.effectiveProbeAPIKey,
|
||||
ExpectedModel: req.ExpectedModel,
|
||||
})
|
||||
if err != nil {
|
||||
return sub2api.GatewayAccessResult{}, fmt.Errorf("check gateway access: %w", err)
|
||||
}
|
||||
result.EffectiveProbeAPIKey = plan.effectiveProbeAPIKey
|
||||
result.EffectiveProbeKeySource = plan.effectiveProbeKeySource
|
||||
if result.OK && result.HasExpectedModel && strings.TrimSpace(req.ExpectedModel) != "" {
|
||||
completionReq := sub2api.GatewayCompletionCheckRequest{
|
||||
APIKey: plan.probeAPIKey,
|
||||
APIKey: plan.effectiveProbeAPIKey,
|
||||
Model: req.ExpectedModel,
|
||||
Prompt: req.Prompt,
|
||||
MaxTokens: req.MaxTokens,
|
||||
|
||||
@@ -6,11 +6,18 @@ import (
|
||||
)
|
||||
|
||||
type closurePlan struct {
|
||||
probeAPIKey string
|
||||
effectiveProbeAPIKey string
|
||||
effectiveProbeKeySource string
|
||||
}
|
||||
|
||||
func (s *Service) prepareClosurePlan(ctx context.Context, req ClosureRequest) (closurePlan, error) {
|
||||
plan := closurePlan{probeAPIKey: strings.TrimSpace(req.ProbeAPIKey)}
|
||||
requestedProbeAPIKey := strings.TrimSpace(req.ProbeAPIKey)
|
||||
plan := closurePlan{
|
||||
effectiveProbeAPIKey: requestedProbeAPIKey,
|
||||
}
|
||||
if requestedProbeAPIKey != "" {
|
||||
plan.effectiveProbeKeySource = ProbeKeySourceRequestedProbeAPIKey
|
||||
}
|
||||
if strings.TrimSpace(req.Mode) != ModeSubscription {
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ func (s *Service) prepareSubscriptionPlan(ctx context.Context, req ClosureReques
|
||||
resolvedTarget = accessRef.UserID
|
||||
}
|
||||
if strings.TrimSpace(accessRef.APIKey) != "" {
|
||||
plan.probeAPIKey = strings.TrimSpace(accessRef.APIKey)
|
||||
plan.effectiveProbeAPIKey = strings.TrimSpace(accessRef.APIKey)
|
||||
plan.effectiveProbeKeySource = ProbeKeySourceManagedSubscription
|
||||
}
|
||||
if _, err := s.host.AssignSubscription(ctx, sub2api.AssignSubscriptionRequest{
|
||||
UserID: resolvedTarget,
|
||||
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
ModeSubscription = "subscription"
|
||||
ModeSelfService = "self_service"
|
||||
|
||||
ProbeKeySourceRequestedProbeAPIKey = "requested_probe_api_key"
|
||||
ProbeKeySourceManagedSubscription = "managed_subscription"
|
||||
|
||||
gatewayCompletionRetryAttempts = 3
|
||||
gatewayCompletionRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -2300,6 +2301,7 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
|
||||
HostBaseURL: server.URL,
|
||||
PackPath: packPath,
|
||||
ProviderID: "deepseek",
|
||||
AccessAPIKey: "caller-probe-key",
|
||||
SubscriptionUsers: []string{"crm-user-1"},
|
||||
SubscriptionDays: 30,
|
||||
})
|
||||
@@ -2317,6 +2319,19 @@ func TestActionSetAssignAccessSubscriptionsClosure(t *testing.T) {
|
||||
if len(closures) != 1 || closures[0].Status != provision.AccessStatusSubscriptionReady {
|
||||
t.Fatalf("AccessClosures() = %+v, want persisted subscription_ready closure", closures)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(closures[0].DetailsJSON), &payload); err != nil {
|
||||
t.Fatalf("decode access closure payload: %v", err)
|
||||
}
|
||||
if _, ok := payload["probe_api_key"]; ok {
|
||||
t.Fatalf("subscription access closure should omit probe_api_key, got %#v", payload["probe_api_key"])
|
||||
}
|
||||
if got, _ := payload["requested_probe_api_key"].(string); got != "caller-probe-key" {
|
||||
t.Fatalf("requested_probe_api_key = %q, want caller-probe-key", got)
|
||||
}
|
||||
if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" {
|
||||
t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got)
|
||||
}
|
||||
batchRow, err := store.ImportBatches().GetByID(context.Background(), batchID)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatches().GetByID() error = %v", err)
|
||||
|
||||
@@ -1367,16 +1367,11 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
}
|
||||
|
||||
accessStatus := deriveAccessStatus(gwResult)
|
||||
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,
|
||||
})
|
||||
accessPayload, _ := json.Marshal(provision.BuildAccessClosureDetails(provision.AccessRequest{
|
||||
Mode: provision.AccessModeSubscription,
|
||||
ProbeAPIKey: req.AccessAPIKey,
|
||||
Subscriptions: subscriptionTargets(req.SubscriptionUsers, req.SubscriptionDays),
|
||||
}, gwResult))
|
||||
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)
|
||||
}
|
||||
@@ -1621,3 +1616,14 @@ func deriveAccessStatus(gw sub2api.GatewayAccessResult) string {
|
||||
}
|
||||
return provision.AccessStatusBroken
|
||||
}
|
||||
|
||||
func subscriptionTargets(userIDs []string, durationDays int) []provision.SubscriptionTarget {
|
||||
targets := make([]provision.SubscriptionTarget, 0, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
targets = append(targets, provision.SubscriptionTarget{
|
||||
UserID: strings.TrimSpace(userID),
|
||||
DurationDays: durationDays,
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
@@ -13,14 +13,16 @@ type GatewayAccessCheckRequest struct {
|
||||
}
|
||||
|
||||
type GatewayAccessResult struct {
|
||||
OK bool `json:"ok"`
|
||||
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"`
|
||||
OK bool `json:"ok"`
|
||||
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"`
|
||||
EffectiveProbeAPIKey string `json:"-"`
|
||||
EffectiveProbeKeySource string `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Client) CheckGatewayAccess(ctx context.Context, req GatewayAccessCheckRequest) (GatewayAccessResult, error) {
|
||||
|
||||
61
internal/provision/access_closure_details.go
Normal file
61
internal/provision/access_closure_details.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/access"
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
)
|
||||
|
||||
func BuildAccessClosureDetails(accessReq AccessRequest, gateway sub2api.GatewayAccessResult) map[string]any {
|
||||
details := map[string]any{
|
||||
"subscription_users": subscriptionUserIDs(accessReq.Subscriptions),
|
||||
"subscription_days": subscriptionDurationDays(accessReq.Subscriptions),
|
||||
"status_code": gateway.StatusCode,
|
||||
"ok": gateway.OK,
|
||||
"has_expected_model": gateway.HasExpectedModel,
|
||||
"models": gateway.Models,
|
||||
"completion_ok": gateway.CompletionOK,
|
||||
"completion_status": gateway.CompletionStatus,
|
||||
"completion_type": gateway.CompletionType,
|
||||
"completion_preview": gateway.CompletionBody,
|
||||
}
|
||||
|
||||
requestedProbeAPIKey := strings.TrimSpace(accessReq.ProbeAPIKey)
|
||||
if requestedProbeAPIKey != "" {
|
||||
details["requested_probe_api_key"] = requestedProbeAPIKey
|
||||
}
|
||||
|
||||
effectiveProbeKeySource := strings.TrimSpace(gateway.EffectiveProbeKeySource)
|
||||
if effectiveProbeKeySource != "" {
|
||||
details["effective_probe_key_source"] = effectiveProbeKeySource
|
||||
}
|
||||
|
||||
effectiveProbeAPIKey := strings.TrimSpace(gateway.EffectiveProbeAPIKey)
|
||||
if effectiveProbeAPIKey != "" {
|
||||
details["effective_probe_key_fingerprint"] = fingerprintSecret(effectiveProbeAPIKey)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(accessReq.Mode) == AccessModeSelfService && requestedProbeAPIKey != "" {
|
||||
details["probe_api_key"] = requestedProbeAPIKey
|
||||
if effectiveProbeKeySource == "" {
|
||||
details["effective_probe_key_source"] = access.ProbeKeySourceRequestedProbeAPIKey
|
||||
}
|
||||
if effectiveProbeAPIKey == "" {
|
||||
details["effective_probe_key_fingerprint"] = fingerprintSecret(requestedProbeAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
func fingerprintSecret(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(trimmed))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -221,19 +221,7 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc
|
||||
}
|
||||
}
|
||||
|
||||
accessPayload, err := json.Marshal(map[string]any{
|
||||
"probe_api_key": strings.TrimSpace(access.ProbeAPIKey),
|
||||
"subscription_users": subscriptionUserIDs(access.Subscriptions),
|
||||
"subscription_days": subscriptionDurationDays(access.Subscriptions),
|
||||
"status_code": report.Gateway.StatusCode,
|
||||
"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,
|
||||
})
|
||||
accessPayload, err := json.Marshal(BuildAccessClosureDetails(access, report.Gateway))
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal gateway access summary: %w", err)
|
||||
}
|
||||
|
||||
@@ -403,6 +403,15 @@ func TestRuntimeImportServicePersistsSelfServiceProbeKeyInAccessClosure(t *testi
|
||||
if got, _ := payload["probe_api_key"].(string); got != "user-probe-key" {
|
||||
t.Fatalf("probe_api_key = %q, want user-probe-key", got)
|
||||
}
|
||||
if got, _ := payload["requested_probe_api_key"].(string); got != "user-probe-key" {
|
||||
t.Fatalf("requested_probe_api_key = %q, want user-probe-key", got)
|
||||
}
|
||||
if got, _ := payload["effective_probe_key_source"].(string); got != "requested_probe_api_key" {
|
||||
t.Fatalf("effective_probe_key_source = %q, want requested_probe_api_key", got)
|
||||
}
|
||||
if got, _ := payload["effective_probe_key_fingerprint"].(string); got != fingerprintSecret("user-probe-key") {
|
||||
t.Fatalf("effective_probe_key_fingerprint = %q, want hashed user-probe-key", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeImportServicePersistsSubscriptionMetadataInAccessClosure(t *testing.T) {
|
||||
@@ -459,6 +468,15 @@ func TestRuntimeImportServicePersistsSubscriptionMetadataInAccessClosure(t *test
|
||||
if got, _ := payload["subscription_days"].(float64); int(got) != 30 {
|
||||
t.Fatalf("subscription_days = %v, want 30", got)
|
||||
}
|
||||
if _, ok := payload["probe_api_key"]; ok {
|
||||
t.Fatalf("probe_api_key should be omitted for subscription closure, got %#v", payload["probe_api_key"])
|
||||
}
|
||||
if got, _ := payload["effective_probe_key_source"].(string); got != "managed_subscription" {
|
||||
t.Fatalf("effective_probe_key_source = %q, want managed_subscription", got)
|
||||
}
|
||||
if got, _ := payload["effective_probe_key_fingerprint"].(string); got != fingerprintSecret("managed-subscription-key") {
|
||||
t.Fatalf("effective_probe_key_fingerprint = %q, want managed-subscription-key fingerprint", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeImportServiceRepeatedImportReusesManagedResources(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user