diff --git a/backend/internal/application/account/credential_refresh_test.go b/backend/internal/application/account/credential_refresh_test.go index c22f3356f..4b4819dec 100644 --- a/backend/internal/application/account/credential_refresh_test.go +++ b/backend/internal/application/account/credential_refresh_test.go @@ -406,6 +406,52 @@ func TestCredentialRefreshFailureDistinguishesTransientAndPermanent(t *testing.T } } +func TestCredentialDecryptFailedAllowsRetryAfterKeyRecovery(t *testing.T) { + ctx := context.Background() + now := time.Now().UTC() + service, credential, adapter := newCredentialRefreshTestService(t, now) + service.now = func() time.Time { return now } + + // 旧行为会把 decrypt_failed 标 permanent;模拟已落库的 permanent 状态。 + if err := service.accounts.UpdateCredentialRefreshFailure(ctx, credential.ID, 1, now.Add(time.Hour), "credential_decrypt_failed", true); err != nil { + t.Fatal(err) + } + stuck, err := service.accounts.Get(ctx, credential.ID) + if err != nil || !stuck.RefreshPermanent || stuck.LastRefreshErrorCode != "credential_decrypt_failed" { + t.Fatalf("setup stuck state = %#v err=%v", stuck, err) + } + + // 密钥恢复后:手动 force 必须能再次发起刷新。 + adapter.refreshErr = nil + service.clearRefreshState(credential.ID) + recovered, err := service.EnsureCredential(ctx, stuck, true) + if err != nil { + t.Fatalf("force refresh after decrypt_failed should retry: %v", err) + } + if recovered.RefreshPermanent || recovered.LastRefreshErrorCode != "" || adapter.refreshCount.Load() < 1 { + t.Fatalf("decrypt_failed was not cleared after successful refresh: %#v count=%d", recovered, adapter.refreshCount.Load()) + } + + // invalid_grant 仍须保持永久阻断。 + service.clearRefreshState(credential.ID) + adapter.refreshErr = &provider.CredentialRefreshError{Status: 400, Code: "invalid_grant", Permanent: true} + if _, err := service.EnsureCredential(ctx, recovered, true); err == nil { + t.Fatal("invalid_grant should fail") + } + blocked, err := service.accounts.Get(ctx, credential.ID) + if err != nil || !blocked.RefreshPermanent || blocked.LastRefreshErrorCode != "invalid_grant" { + t.Fatalf("invalid_grant permanent state = %#v err=%v", blocked, err) + } + // force 也不得再打 OAuth(真正永久) + count := adapter.refreshCount.Load() + if _, err := service.EnsureCredential(ctx, blocked, true); err == nil { + t.Fatal("invalid_grant force should still be blocked") + } + if adapter.refreshCount.Load() != count { + t.Fatalf("invalid_grant forced another oauth call: before=%d after=%d", count, adapter.refreshCount.Load()) + } +} + func TestRefreshAllTokensSkipsUnrefreshableAccounts(t *testing.T) { ctx := context.Background() now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC) diff --git a/backend/internal/application/account/credential_scheduler.go b/backend/internal/application/account/credential_scheduler.go index 5ece117b2..1740be4b1 100644 --- a/backend/internal/application/account/credential_scheduler.go +++ b/backend/internal/application/account/credential_scheduler.go @@ -56,7 +56,7 @@ func (s *Service) RecoverCriticalCredentials(ctx context.Context, expiresWithin if getErr != nil { return getErr } - if credential.RefreshPermanent { + if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) { if !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(s.now()) { return nil } @@ -130,7 +130,7 @@ func (s *Service) refreshDueCredentials(ctx context.Context) error { if !credential.Enabled || credential.AuthStatus != accountdomain.AuthStatusActive || s.providers == nil || !s.providers.SupportsCredentialRefresh(credential.Provider) || credential.EncryptedRefreshToken == "" { return nil } - if credential.RefreshPermanent { + if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) { if !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(s.now()) { return nil } diff --git a/backend/internal/application/account/service.go b/backend/internal/application/account/service.go index ee279e1fd..690d478d5 100644 --- a/backend/internal/application/account/service.go +++ b/backend/internal/application/account/service.go @@ -1744,8 +1744,14 @@ func (s *Service) recordCredentialRefreshFailure(ctx context.Context, credential } else if errors.Is(refreshErr, context.DeadlineExceeded) { errorCode = "oauth_timeout" } - // 永久失败只能由成功换取新 token 清除,后续偶发传输错误不能把状态降级为可重试。 - permanent = permanent || credential.RefreshPermanent + // 真正的 OAuth 永久失败(invalid_grant 等)只能由成功换 token 清除。 + // credential_decrypt_failed 是可恢复本地错误:不得被旧 permanent 粘住,也不得把本次可恢复失败抬升为永久。 + if permanent && isRecoverableRefreshErrorCode(errorCode) { + permanent = false + } + if credential.RefreshPermanent && !isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) && !isRecoverableRefreshErrorCode(errorCode) { + permanent = true + } now := s.now() retryAt := now.Add(credentialRefreshBackoff(credential.ID, failureCount, retryAfter)) accessTokenAlive := credential.EncryptedAccessToken != "" && !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(now) @@ -1774,10 +1780,16 @@ func (s *Service) recordCredentialRefreshFailure(ctx context.Context, credential } // resolvePermanentRefreshFailure 阻止再次请求已确认失效的 refresh token,并在 access token 到期后收敛账号状态。 +// credential_decrypt_failed 属于本地密钥问题,允许手动 force / 调度重试(密钥恢复后可自愈); +// invalid_grant 等真正 OAuth 永久失败仍保持阻断。 func (s *Service) resolvePermanentRefreshFailure(ctx context.Context, credential accountdomain.Credential, now time.Time, force bool) (accountdomain.Credential, error, bool) { if !credential.RefreshPermanent { return accountdomain.Credential{}, nil, false } + if isRecoverableRefreshErrorCode(credential.LastRefreshErrorCode) { + // 允许 force 或到期调度再次尝试解密/刷新;成功后会 clear permanent 标记。 + return accountdomain.Credential{}, nil, false + } accessTokenAlive := credential.EncryptedAccessToken != "" && !credential.ExpiresAt.IsZero() && credential.ExpiresAt.After(now) if accessTokenAlive && !force { return credential, nil, true @@ -1793,6 +1805,16 @@ func (s *Service) resolvePermanentRefreshFailure(ctx context.Context, credential return accountdomain.Credential{}, fmt.Errorf("%w: %s", ErrCredentialRefreshPermanent, credential.LastRefreshErrorCode), true } +// isRecoverableRefreshErrorCode 标识“永久标记可被后续成功刷新清除”的本地/临时错误。 +func isRecoverableRefreshErrorCode(code string) bool { + switch strings.TrimSpace(code) { + case "credential_decrypt_failed": + return true + default: + return false + } +} + func credentialRefreshBackoff(accountID uint64, failureCount int, retryAfter time.Duration) time.Duration { delays := [...]time.Duration{30 * time.Second, 2 * time.Minute, 5 * time.Minute, 10 * time.Minute, 15 * time.Minute} index := max(0, min(failureCount-1, len(delays)-1)) diff --git a/backend/internal/application/gateway/official_cache_e2e_test.go b/backend/internal/application/gateway/official_cache_e2e_test.go new file mode 100644 index 000000000..6cace5a0d --- /dev/null +++ b/backend/internal/application/gateway/official_cache_e2e_test.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "testing" + + accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" +) + +// 模拟 Sub2API → Grok2API 官方缓存亲和链路(不连真上游)。 +func TestOfficialCache_Sub2APIPromptCacheKeyStickyAcrossTurns(t *testing.T) { + // Sub2API 透传 body.prompt_cache_key + explicit := "sub2api-stable-session-uuid-001" + turn1Body := []byte(`{"model":"grok-4.5","prompt_cache_key":"sub2api-stable-session-uuid-001","messages":[{"role":"user","content":"hello"}]}`) + turn2Body := []byte(`{"model":"grok-4.5","prompt_cache_key":"sub2api-stable-session-uuid-001","messages":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"again"}]}`) + + id1 := resolveBuildSessionIdentity(42, accountdomain.ProviderBuild, "grok-4.5", explicit, "", turn1Body) + id2 := resolveBuildSessionIdentity(42, accountdomain.ProviderBuild, "grok-4.5", explicit, "", turn2Body) + if id1.upstreamID == "" || id1.upstreamID != id2.upstreamID { + t.Fatalf("sub2api prompt_cache_key must yield stable upstream id: %#v %#v", id1, id2) + } + if id1.affinityKey == "" || id1.affinityKey != id2.affinityKey { + t.Fatalf("affinity must stick across turns: %#v %#v", id1, id2) + } + // 不同租户(clientKey)必须隔离 + other := resolveBuildSessionIdentity(99, accountdomain.ProviderBuild, "grok-4.5", explicit, "", turn1Body) + if other.upstreamID == id1.upstreamID { + t.Fatal("tenant isolation broken for explicit prompt_cache_key") + } +} + +func TestOfficialCache_Sub2APIMissingSessionFallsBackToMessageHash(t *testing.T) { + // Sub2API 没透传任何 session:靠首条 user 内容 soft 粘滞 + turn1 := []byte(`{"messages":[{"role":"system","content":"sys"},{"role":"user","content":"what is mutex"}]}`) + turn2 := []byte(`{"messages":[{"role":"system","content":"sys"},{"role":"user","content":"what is mutex"},{"role":"assistant","content":"A lock."},{"role":"user","content":"and deadlock?"}]}`) + id1 := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", turn1) + id2 := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", turn2) + if !id1.soft || id1.upstreamID == "" { + t.Fatalf("expected soft session, got %#v", id1) + } + if id1.upstreamID != id2.upstreamID { + t.Fatalf("soft session drifted: %s vs %s", id1.upstreamID, id2.upstreamID) + } +} + +func TestOfficialCache_NoSignalMeansNoRandomConvID(t *testing.T) { + // 完全无信号:不得生成上游 ID(由 adapter 侧保证不写随机 conv-id) + id := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{}`)) + if id.upstreamID != "" || id.affinityKey != "" { + t.Fatalf("empty signal must not invent session: %#v", id) + } +} + +func TestOfficialCache_ClaudeCodeSessionHeaderPath(t *testing.T) { + // 模拟 handler 已把 X-Claude-Code-Session-Id 抽成 seed + seed := "123e4567-e89b-12d3-a456-426614174000" + a := resolveBuildSessionIdentity(1, accountdomain.ProviderBuild, "grok-4.5", "", seed, nil) + b := resolveBuildSessionIdentity(1, accountdomain.ProviderBuild, "grok-4.5", "", seed, []byte(`{"messages":[{"role":"user","content":"x"}]}`)) + if a.upstreamID == "" || a.upstreamID != b.upstreamID { + t.Fatalf("claude session seed unstable: %#v %#v", a, b) + } +} diff --git a/backend/internal/application/gateway/prompt_cache.go b/backend/internal/application/gateway/prompt_cache.go index 6b3e38298..581ad858e 100644 --- a/backend/internal/application/gateway/prompt_cache.go +++ b/backend/internal/application/gateway/prompt_cache.go @@ -3,8 +3,10 @@ package gateway import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "strings" + "unicode/utf8" accountdomain "github.com/chenyme/grok2api/backend/internal/domain/account" ) @@ -12,27 +14,52 @@ import ( const buildSessionIdentityVersion = "v2" type buildSessionIdentity struct { - upstreamID string + // upstreamID 写入上游 prompt_cache_key 与 x-grok-conv-id,须跨轮稳定。 + upstreamID string + // affinityKey 用于账号粘滞;按模型隔离,避免不同模型能力账号互相覆盖。 affinityKey string + // replayKey 仅由客户端显式会话信号生成;soft 消息锚点不得驱动 encrypted reasoning 回放。 + replayKey string + // soft 表示由消息内容兜底生成(无显式 session 时),仍须稳定。 + soft bool } -// resolveBuildSessionIdentity 将客户端缓存键或会话标识拆分为两种用途: -// upstreamID 在同一客户端会话内跨协议、跨模型保持稳定,用于上游缓存和 CLI 会话 Header; -// affinityKey 额外包含上游模型,用于账号粘滞,避免不同模型能力的账号相互覆盖绑定。 -func resolveBuildSessionIdentity(clientKeyID uint64, provider accountdomain.Provider, upstreamModel, explicitKey, sessionSeed string) buildSessionIdentity { +// resolveBuildSessionIdentity 对齐 CPA 官方缓存会话策略: +// 1) 显式 prompt_cache_key / session seed → 稳定哈希身份(多租户隔离) +// 2) 否则用消息锚点(system + 首条 user)生成 soft 身份,保证多轮粘滞与 conv-id 不漂移 +// 3) 完全无信号 → 空身份(禁止每请求随机 ID,避免打散 xAI 服务器亲和) +func resolveBuildSessionIdentity(clientKeyID uint64, provider accountdomain.Provider, upstreamModel, explicitKey, sessionSeed string, body []byte) buildSessionIdentity { seed := strings.TrimSpace(explicitKey) if seed == "" { seed = strings.TrimSpace(sessionSeed) } model := strings.ToLower(strings.TrimSpace(upstreamModel)) - if clientKeyID == 0 || provider == "" || model == "" || seed == "" { + if clientKeyID == 0 || provider == "" || model == "" { return buildSessionIdentity{} } - upstreamSource := fmt.Sprintf("grok2api:build-session:%s:%d:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, seed) - affinitySource := fmt.Sprintf("grok2api:build-affinity:%s:%d:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, seed) + if seed != "" { + upstreamSource := fmt.Sprintf("grok2api:build-session:%s:%d:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, seed) + affinitySource := fmt.Sprintf("grok2api:build-affinity:%s:%d:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, seed) + replaySource := fmt.Sprintf("grok2api:build-replay:%s:%d:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, seed) + return buildSessionIdentity{ + upstreamID: digestUUID(upstreamSource), + affinityKey: hexDigest(affinitySource), + replayKey: hexDigest(replaySource), + } + } + // CPA 风格消息 hash 兜底:无 session 时仍尽量粘账号 + 稳定 conv-id,服务 Sub2API 未透传 session 的场景。 + system, firstUser, _ := extractMessageAnchors(body) + firstUser = truncateAnchor(firstUser, 200) + system = truncateAnchor(system, 100) + if firstUser == "" { + return buildSessionIdentity{} + } + upstreamSource := fmt.Sprintf("grok2api:build-soft-session:%s:%d:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, system, firstUser) + affinitySource := fmt.Sprintf("grok2api:build-soft-affinity:%s:%d:%s:%s:%s:%s", buildSessionIdentityVersion, clientKeyID, provider, model, system, firstUser) return buildSessionIdentity{ upstreamID: digestUUID(upstreamSource), affinityKey: hexDigest(affinitySource), + soft: true, } } @@ -46,3 +73,180 @@ func hexDigest(source string) string { digest := sha256.Sum256([]byte(source)) return hex.EncodeToString(digest[:]) } + +func truncateAnchor(value string, maxRunes int) string { + value = strings.TrimSpace(value) + if value == "" || maxRunes <= 0 { + return value + } + if utf8.RuneCountInString(value) <= maxRunes { + return value + } + runes := []rune(value) + return string(runes[:maxRunes]) +} + +// extractMessageAnchors 从 Chat / Messages / Responses 请求体提取稳定前缀锚点。 +// 仅使用 system + 首条 user(及可选首条 assistant),避免后续轮次追加导致 hash 漂移。 +func extractMessageAnchors(body []byte) (system, firstUser, firstAssistant string) { + if len(body) == 0 { + return "", "", "" + } + var root map[string]json.RawMessage + if json.Unmarshal(body, &root) != nil { + return "", "", "" + } + // 顶层 system / instructions(OpenAI Responses / Chat 常见)作为稳定前缀 system 锚点。 + if raw, ok := root["instructions"]; ok { + system = flattenMessageContent(raw) + } + if system == "" { + if raw, ok := root["system"]; ok { + system = flattenMessageContent(raw) + } + } + if raw, ok := root["messages"]; ok { + msgSystem, msgUser, msgAssistant := anchorsFromRoleMessages(raw) + if system == "" { + system = msgSystem + } + firstUser, firstAssistant = msgUser, msgAssistant + if firstUser != "" { + return system, firstUser, firstAssistant + } + } + if raw, ok := root["input"]; ok { + inSystem, inUser, inAssistant := anchorsFromResponsesInput(raw) + if system == "" { + system = inSystem + } + if firstUser == "" { + firstUser = inUser + } + if firstAssistant == "" { + firstAssistant = inAssistant + } + } + return system, firstUser, firstAssistant +} + +func anchorsFromRoleMessages(raw json.RawMessage) (system, firstUser, firstAssistant string) { + var messages []map[string]json.RawMessage + if json.Unmarshal(raw, &messages) != nil { + return "", "", "" + } + for _, msg := range messages { + var role string + _ = json.Unmarshal(msg["role"], &role) + content := flattenMessageContent(msg["content"]) + if content == "" { + continue + } + switch strings.ToLower(strings.TrimSpace(role)) { + case "system": + if system == "" { + system = content + } + case "user": + if firstUser == "" { + firstUser = content + } + case "assistant": + if firstAssistant == "" { + firstAssistant = content + } + } + if system != "" && firstUser != "" && firstAssistant != "" { + break + } + } + return system, firstUser, firstAssistant +} + +func anchorsFromResponsesInput(raw json.RawMessage) (system, firstUser, firstAssistant string) { + // 简写:input 直接是字符串 + var asString string + if json.Unmarshal(raw, &asString) == nil { + return "", strings.TrimSpace(asString), "" + } + var items []map[string]json.RawMessage + if json.Unmarshal(raw, &items) != nil { + return "", "", "" + } + for _, item := range items { + var typeName, role string + _ = json.Unmarshal(item["type"], &typeName) + _ = json.Unmarshal(item["role"], &role) + typeName = strings.TrimSpace(typeName) + role = strings.ToLower(strings.TrimSpace(role)) + // instructions 级 system 由顶层处理;此处抓 message + if typeName != "" && typeName != "message" { + continue + } + content := flattenMessageContent(item["content"]) + if content == "" { + // 兼容 content 为字符串字段 text + var text string + if json.Unmarshal(item["text"], &text) == nil { + content = strings.TrimSpace(text) + } + } + if content == "" { + continue + } + switch role { + case "system", "developer": + if system == "" { + system = content + } + case "user": + if firstUser == "" { + firstUser = content + } + case "assistant": + if firstAssistant == "" { + firstAssistant = content + } + default: + // 无 role 的纯文本 input 项视为 user + if role == "" && firstUser == "" && (typeName == "" || typeName == "message") { + firstUser = content + } + } + if firstUser != "" && firstAssistant != "" { + break + } + } + // 顶层 instructions 作为 system 补充 + return system, firstUser, firstAssistant +} + +func flattenMessageContent(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var asString string + if json.Unmarshal(raw, &asString) == nil { + return strings.TrimSpace(asString) + } + var parts []map[string]json.RawMessage + if json.Unmarshal(raw, &parts) != nil { + return "" + } + var builder strings.Builder + for _, part := range parts { + var partType string + _ = json.Unmarshal(part["type"], &partType) + switch strings.TrimSpace(partType) { + case "", "text", "input_text", "output_text": + var text string + if json.Unmarshal(part["text"], &text) == nil && strings.TrimSpace(text) != "" { + if builder.Len() > 0 { + builder.WriteByte('\n') + } + builder.WriteString(strings.TrimSpace(text)) + } + } + } + return builder.String() +} diff --git a/backend/internal/application/gateway/prompt_cache_test.go b/backend/internal/application/gateway/prompt_cache_test.go index 4513e8c49..497fd4c2f 100644 --- a/backend/internal/application/gateway/prompt_cache_test.go +++ b/backend/internal/application/gateway/prompt_cache_test.go @@ -7,24 +7,24 @@ import ( ) func TestResolveBuildSessionIdentityIsStableAndTenantIsolated(t *testing.T) { - base := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1") - if len(base.upstreamID) != 36 || len(base.affinityKey) != 64 || base != resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1") { + base := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil) + if len(base.upstreamID) != 36 || len(base.affinityKey) != 64 || len(base.replayKey) != 64 || base != resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil) { t.Fatalf("unstable identity = %#v", base) } for name, value := range map[string]buildSessionIdentity{ - "client": resolveBuildSessionIdentity(8, accountdomain.ProviderBuild, "grok-4.5", "", "session-1"), - "provider": resolveBuildSessionIdentity(7, accountdomain.ProviderConsole, "grok-4.5", "", "session-1"), - "session": resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-2"), + "client": resolveBuildSessionIdentity(8, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil), + "provider": resolveBuildSessionIdentity(7, accountdomain.ProviderConsole, "grok-4.5", "", "session-1", nil), + "session": resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-2", nil), } { - if value == base { - t.Fatalf("%s was not isolated: %#v", name, value) + if value.upstreamID == base.upstreamID || value.affinityKey == base.affinityKey { + t.Fatalf("%s was not isolated: %#v vs %#v", name, value, base) } } } func TestResolveBuildSessionIdentitySeparatesAffinityFromUpstreamSession(t *testing.T) { - first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1") - otherModel := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.3", "", "session-1") + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "session-1", nil) + otherModel := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.3", "", "session-1", nil) if first.upstreamID == "" || first.upstreamID != otherModel.upstreamID { t.Fatalf("upstream session changed with model: first=%#v other=%#v", first, otherModel) } @@ -34,15 +34,61 @@ func TestResolveBuildSessionIdentitySeparatesAffinityFromUpstreamSession(t *test } func TestResolveBuildSessionIdentityPrefersExplicitKey(t *testing.T) { - first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "session-1") - second := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "session-2") + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "session-1", nil) + second := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "client-key", "session-2", nil) if first.upstreamID == "" || first != second { t.Fatalf("explicit key did not take precedence: first=%#v second=%#v", first, second) } - if value := resolveBuildSessionIdentity(0, accountdomain.ProviderBuild, "grok-4.5", "client-key", ""); value != (buildSessionIdentity{}) { + if value := resolveBuildSessionIdentity(0, accountdomain.ProviderBuild, "grok-4.5", "client-key", "", nil); value != (buildSessionIdentity{}) { t.Fatal("identity without client ownership should be empty") } - if value := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", ""); value != (buildSessionIdentity{}) { - t.Fatal("identity without an explicit key or session should be empty") + if value := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", nil); value != (buildSessionIdentity{}) { + t.Fatal("identity without an explicit key or session or body should be empty") + } +} + +func TestResolveBuildSessionIdentitySoftFromMessagesIsStableAcrossTurns(t *testing.T) { + turn1 := []byte(`{"messages":[{"role":"system","content":"rules"},{"role":"user","content":"hello world"}]}`) + turn2 := []byte(`{"messages":[{"role":"system","content":"rules"},{"role":"user","content":"hello world"},{"role":"assistant","content":"hi"},{"role":"user","content":"next"}]}`) + first := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", turn1) + second := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", turn2) + if !first.soft || first.upstreamID == "" { + t.Fatalf("expected soft identity, got %#v", first) + } + if first.replayKey != "" { + t.Fatalf("soft identity must not enable reasoning replay: %#v", first) + } + if first.upstreamID != second.upstreamID || first.affinityKey != second.affinityKey { + t.Fatalf("soft identity drifted across turns: first=%#v second=%#v", first, second) + } + // 不同首条用户内容必须隔离 + other := resolveBuildSessionIdentity(7, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"messages":[{"role":"user","content":"different"}]}`)) + if other.upstreamID == first.upstreamID { + t.Fatal("different first user shared soft upstream id") + } +} + +func TestResolveBuildSessionIdentitySoftFromResponsesInput(t *testing.T) { + body := []byte(`{"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"cache me"}]}]}`) + first := resolveBuildSessionIdentity(9, accountdomain.ProviderBuild, "grok-4.5", "", "", body) + second := resolveBuildSessionIdentity(9, accountdomain.ProviderBuild, "grok-4.5", "", "", body) + if first.upstreamID == "" || first != second || !first.soft { + t.Fatalf("responses soft identity unstable: %#v %#v", first, second) + } + if first.replayKey != "" { + t.Fatalf("responses soft identity must not enable reasoning replay: %#v", first) + } +} + +func TestResolveBuildSessionIdentityUsesInstructionsAsSystemAnchor(t *testing.T) { + // Responses 协议常用顶层 instructions 而不是 messages[system] + a := resolveBuildSessionIdentity(3, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"instructions":"stable system","input":[{"role":"user","content":"hello"}]}`)) + b := resolveBuildSessionIdentity(3, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"instructions":"stable system","input":[{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"next"}]}`)) + c := resolveBuildSessionIdentity(3, accountdomain.ProviderBuild, "grok-4.5", "", "", []byte(`{"instructions":"other system","input":[{"role":"user","content":"hello"}]}`)) + if !a.soft || a.upstreamID == "" || a.upstreamID != b.upstreamID { + t.Fatalf("instructions soft session unstable: %#v %#v", a, b) + } + if a.upstreamID == c.upstreamID { + t.Fatal("different instructions should isolate soft session") } } diff --git a/backend/internal/application/gateway/service.go b/backend/internal/application/gateway/service.go index 98f775754..288eb5ef0 100644 --- a/backend/internal/application/gateway/service.go +++ b/backend/internal/application/gateway/service.go @@ -440,16 +440,35 @@ func (s *Service) createResponseAt(ctx context.Context, input Input, path string return nil, clientkeyapp.ErrModelNotAllowed } affinityKey := "" + ownershipPromptCacheKey := "" + reasoningReplayKey := "" if route.Provider == accountdomain.ProviderBuild { - identity := resolveBuildSessionIdentity( - input.ClientKey.ID, - route.Provider, - route.UpstreamModel, - input.PromptCacheKey, - input.PromptCacheSeed, - ) + // 显式 key / session seed / 消息锚点 soft session(CPA 风格),禁止空 session 随机 conv-id。 + identity := buildSessionIdentity{} + if ownership != nil && ownership.PromptCacheKey != "" { + // previous_response_id 属于既有 Response 链,必须继承根会话身份, + // 不能用本轮增量 input 重新计算 soft key。 + identity.upstreamID = ownership.PromptCacheKey + identity.replayKey = ownership.ReasoningReplayKey + } else { + identity = resolveBuildSessionIdentity( + input.ClientKey.ID, + route.Provider, + route.UpstreamModel, + input.PromptCacheKey, + input.PromptCacheSeed, + input.Body, + ) + } input.PromptCacheKey = identity.upstreamID affinityKey = identity.affinityKey + ownershipPromptCacheKey = identity.upstreamID + reasoningReplayKey = identity.replayKey + if identity.upstreamID == "" { + s.logger.Debug("prompt_cache_session_empty", "request_id", input.RequestID, "model", route.UpstreamModel, "provider", route.Provider) + } else if identity.soft { + s.logger.Debug("prompt_cache_session_soft", "request_id", input.RequestID, "model", route.UpstreamModel) + } } adapter, ok := s.providers.Responses(route.Provider) if !ok { @@ -483,7 +502,7 @@ func (s *Service) createResponseAt(ctx context.Context, input Input, path string failureAttempts := newFailureAttemptRecorder(http.MethodPost, path) forwardResponse := func(credential accountdomain.Credential, billing *accountdomain.Billing) (*provider.Response, error) { started := time.Now() - response, err := adapter.ForwardResponse(ctx, provider.ResponseResourceRequest{Credential: credential, Billing: billing, Method: http.MethodPost, Path: path, Model: route.UpstreamModel, PromptCacheKey: input.PromptCacheKey, IdempotencyID: idempotencyID, Body: input.Body, Streaming: input.Streaming, NormalizeBody: true, Operation: string(operation)}) + response, err := adapter.ForwardResponse(ctx, provider.ResponseResourceRequest{Credential: credential, Billing: billing, Method: http.MethodPost, Path: path, Model: route.UpstreamModel, PromptCacheKey: input.PromptCacheKey, ReasoningReplayKey: reasoningReplayKey, IdempotencyID: idempotencyID, Body: input.Body, Streaming: input.Streaming, NormalizeBody: true, Operation: string(operation)}) err = failureAttempts.captureResponse(credential, started, response, err) timing.markUpstream(time.Since(started)) return response, err @@ -789,7 +808,9 @@ attemptLoop: } } if supportsStoredResponses && operation == audit.OperationResponses && responseID != "" && response.StatusCode >= 200 && response.StatusCode < 300 { - _ = s.responses.Save(persistCtx, inferencedomain.ResponseOwnership{ResponseID: responseID, AccountID: accountID, ClientKeyID: input.ClientKey.ID, Provider: route.Provider, ExpiresAt: now.Add(responseOwnershipTTL), CreatedAt: now, UpdatedAt: now}) + if err := s.responses.Save(persistCtx, inferencedomain.ResponseOwnership{ResponseID: responseID, AccountID: accountID, ClientKeyID: input.ClientKey.ID, Provider: route.Provider, PromptCacheKey: ownershipPromptCacheKey, ReasoningReplayKey: reasoningReplayKey, ExpiresAt: now.Add(responseOwnershipTTL), CreatedAt: now, UpdatedAt: now}); err != nil { + s.logger.Error("response_ownership_save_failed", "response_id", responseID, "client_key_id", input.ClientKey.ID, "account_id", accountID, "provider", route.Provider, "error", err) + } } outcome := "failed" if response.StatusCode >= 200 && response.StatusCode < 300 && errorCode == "" { diff --git a/backend/internal/application/gateway/service_test.go b/backend/internal/application/gateway/service_test.go index 065ff808e..d316c9a61 100644 --- a/backend/internal/application/gateway/service_test.go +++ b/backend/internal/application/gateway/service_test.go @@ -140,11 +140,14 @@ func TestGatewayFailsOverBeforeReturningBody(t *testing.T) { if len(adapter.attempts) != 2 || adapter.attempts[0] != first.ID || adapter.attempts[1] != second.ID { t.Fatalf("attempts = %#v", adapter.attempts) } - identity := resolveBuildSessionIdentity(clientKey.ID, account.ProviderBuild, "grok-test", "", "claude-session") + identity := resolveBuildSessionIdentity(clientKey.ID, account.ProviderBuild, "grok-test", "", "claude-session", nil) expectedCacheKey := identity.upstreamID if adapter.lastPromptCacheKey != expectedCacheKey { t.Fatalf("prompt cache key = %q, want %q", adapter.lastPromptCacheKey, expectedCacheKey) } + if adapter.lastReasoningReplayKey != identity.replayKey { + t.Fatalf("reasoning replay key = %q, want %q", adapter.lastReasoningReplayKey, identity.replayKey) + } if boundID, ok, err := sticky.Get(ctx, stickySessionKey(identity.affinityKey), time.Now().UTC()); err != nil || !ok || boundID != second.ID { t.Fatalf("failover sticky binding = %d, %v, err = %v; want account %d", boundID, ok, err, second.ID) } @@ -161,7 +164,7 @@ func TestGatewayFailsOverBeforeReturningBody(t *testing.T) { t.Fatalf("audit detail = %#v, err = %v", detail, err) } ownership, err := responseRepo.Get(ctx, "resp-test", clientKey.ID, time.Now().UTC()) - if err != nil || ownership.AccountID != second.ID { + if err != nil || ownership.AccountID != second.ID || ownership.PromptCacheKey != expectedCacheKey || ownership.ReasoningReplayKey != identity.replayKey { t.Fatalf("ownership = %#v, err = %v", ownership, err) } @@ -194,6 +197,13 @@ func TestGatewayFailsOverBeforeReturningBody(t *testing.T) { if len(adapter.attempts) != 1 || adapter.attempts[0] != second.ID { t.Fatalf("continued attempts = %#v", adapter.attempts) } + if adapter.lastPromptCacheKey != expectedCacheKey || adapter.lastReasoningReplayKey != identity.replayKey { + t.Fatalf("continued session identity drifted: cache=%q replay=%q", adapter.lastPromptCacheKey, adapter.lastReasoningReplayKey) + } + nextOwnership, err := responseRepo.Get(ctx, "resp-next", clientKey.ID, time.Now().UTC()) + if err != nil || nextOwnership.PromptCacheKey != expectedCacheKey || nextOwnership.ReasoningReplayKey != identity.replayKey { + t.Fatalf("continued ownership = %#v, err = %v", nextOwnership, err) + } adapter.resetAttempts() resource, err := service.GetResponse(ctx, ResourceInput{ClientKey: clientKey, ResponseID: "resp-test", RawQuery: "include=reasoning.encrypted_content"}) @@ -580,6 +590,69 @@ func TestGatewayDoesNotPersistStatelessConsoleResponses(t *testing.T) { } } +func TestGatewayWebOwnershipDoesNotPersistRawPromptCacheKey(t *testing.T) { + ctx := context.Background() + database, err := relational.OpenSQLite(ctx, filepath.Join(t.TempDir(), "web-ownership.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + accountRepo := relational.NewAccountRepository(database) + modelRepo := relational.NewModelRepository(database) + auditRepo := relational.NewAuditRepository(database) + responseRepo := relational.NewResponseRepository(database) + keyRepo := relational.NewClientKeyRepository(database) + credential, _, err := accountRepo.UpsertByIdentity(ctx, account.Credential{ + Provider: account.ProviderWeb, AuthType: account.AuthTypeSSO, Name: "web", SourceKey: "web", + EncryptedAccessToken: "encrypted", Enabled: true, AuthStatus: account.AuthStatusActive, MaxConcurrent: 1, + }) + if err != nil { + t.Fatal(err) + } + const model = "grok-web-ownership" + if err := modelRepo.UpsertDiscovered(ctx, account.ProviderWeb, []string{model}); err != nil { + t.Fatal(err) + } + if err := modelRepo.ReplaceAccountCapabilities(ctx, credential.ID, []string{model}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + key, err := keyRepo.Create(ctx, clientkey.Key{ + Name: "web-key", Prefix: "web-key", SecretHash: strings.Repeat("d", 64), EncryptedSecret: "encrypted", + Enabled: true, RPMLimit: 60, MaxConcurrent: 4, + }) + if err != nil { + t.Fatal(err) + } + adapter := webStoredResponseAdapter{} + registry := provider.NewRegistry(adapter) + sticky := memory.NewStickyStore() + accountService := accountapp.NewService(accountRepo, auditRepo, memory.NewDeviceSessionStore(), sticky, registry, testCipher(t), nil) + selector := NewSelector(accountRepo, memory.NewConcurrencyLimiter(), sticky, registry, time.Hour, time.Second, time.Minute) + service := NewService(modelRepo, auditRepo, accountService, clientkeyapp.NewService(nil, nil, nil, 60, 4, nil), registry, selector, responseRepo, 1) + + rawKey := strings.Repeat("raw-session-", 16) + result, err := service.CreateResponse(ctx, Input{ + RequestID: "req-web-ownership", ClientKey: key, PublicModel: model, PromptCacheKey: rawKey, + Body: []byte(`{"model":"grok-web-ownership","input":"hello"}`), + }) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(result.Body) + result.Finalize(Usage{}, "resp-web-ownership", "") + _ = result.Body.Close() + ownership, err := responseRepo.Get(ctx, "resp-web-ownership", key.ID, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if ownership.AccountID != credential.ID || ownership.Provider != account.ProviderWeb || ownership.PromptCacheKey != "" || ownership.ReasoningReplayKey != "" { + t.Fatalf("web ownership = %#v", ownership) + } +} + func TestParseFreeQuotaExhaustion(t *testing.T) { body := []byte(`{"error":{"code":"subscription:free-usage-exhausted","message":"tokens (actual/limit): 1065387/1000000; Usage resets over a rolling 24-hour window"}}`) used, limit, exhausted := parseFreeQuotaExhaustion(body) @@ -1284,13 +1357,14 @@ func runQuotaRefreshWorkers(t *testing.T, service *accountapp.Service) { } type failoverAdapter struct { - mu sync.Mutex - firstID uint64 - attempts []uint64 - lastMethod string - lastPath string - lastPromptCacheKey string - resourceStatus int + mu sync.Mutex + firstID uint64 + attempts []uint64 + lastMethod string + lastPath string + lastPromptCacheKey string + lastReasoningReplayKey string + resourceStatus int } type ssoUnauthorizedAdapter struct { @@ -1444,6 +1518,25 @@ func (a *systemicForbiddenAdapter) Attempts() []uint64 { return append([]uint64(nil), a.attempts...) } +type webStoredResponseAdapter struct{} + +func (webStoredResponseAdapter) Provider() account.Provider { return account.ProviderWeb } +func (webStoredResponseAdapter) Definition() provider.Definition { + return provider.Definition{ + Provider: account.ProviderWeb, + Conversation: provider.ConversationSurface{ + Responses: true, StoredResponses: true, + }, + Inference: provider.InferencePolicy{Usage: provider.UsageEstimated}, + } +} +func (webStoredResponseAdapter) ForwardResponse(context.Context, provider.ResponseResourceRequest) (*provider.Response, error) { + return &provider.Response{ + StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"id":"resp-web-ownership"}`)), + }, nil +} + type webRateLimitAdapter struct{} type webImageStreamAdapter struct { @@ -1626,6 +1719,7 @@ func (a *failoverAdapter) ForwardResponse(_ context.Context, request provider.Re a.lastMethod = request.Method a.lastPath = request.Path a.lastPromptCacheKey = request.PromptCacheKey + a.lastReasoningReplayKey = request.ReasoningReplayKey resourceStatus := a.resourceStatus a.mu.Unlock() status, body := http.StatusOK, "ok" @@ -1650,6 +1744,7 @@ func (a *failoverAdapter) resetAttempts() { a.lastMethod = "" a.lastPath = "" a.lastPromptCacheKey = "" + a.lastReasoningReplayKey = "" } func (a *failoverAdapter) ListModels(context.Context, account.Credential) ([]string, error) { return nil, nil diff --git a/backend/internal/domain/inference/response.go b/backend/internal/domain/inference/response.go index 626e0bf9b..eb0967b87 100644 --- a/backend/internal/domain/inference/response.go +++ b/backend/internal/domain/inference/response.go @@ -8,13 +8,15 @@ import ( // ResponseOwnership 记录上游 Response 资源所属账号,不保存请求或响应正文。 type ResponseOwnership struct { - ResponseID string - AccountID uint64 - ClientKeyID uint64 - Provider account.Provider - ExpiresAt time.Time - CreatedAt time.Time - UpdatedAt time.Time + ResponseID string + AccountID uint64 + ClientKeyID uint64 + Provider account.Provider + PromptCacheKey string + ReasoningReplayKey string + ExpiresAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } // WebResponseState 保存 Grok Web 本地 Responses 资源及其上游会话游标。 diff --git a/backend/internal/infra/persistence/relational/models.go b/backend/internal/infra/persistence/relational/models.go index d7b5a0932..64fc4eb42 100644 --- a/backend/internal/infra/persistence/relational/models.go +++ b/backend/internal/infra/persistence/relational/models.go @@ -340,15 +340,17 @@ type requestAuditAttemptModel struct { func (requestAuditAttemptModel) TableName() string { return "request_audit_attempts" } type responseOwnershipModel struct { - ResponseID string `gorm:"size:255;primaryKey;check:chk_response_ownership_id,length(response_id) BETWEEN 1 AND 255"` - AccountID uint64 `gorm:"not null"` - ClientKeyID uint64 `gorm:"not null"` - Provider string `gorm:"size:32;not null;check:chk_response_ownership_provider,provider IN ('grok_build','grok_web','grok_console')"` - ExpiresAt time.Time `gorm:"not null;check:chk_response_ownership_expiry,expires_at > created_at"` - CreatedAt time.Time `gorm:"not null"` - UpdatedAt time.Time `gorm:"not null"` - Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` - ClientKey *clientKeyModel `gorm:"foreignKey:ClientKeyID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + ResponseID string `gorm:"size:255;primaryKey;check:chk_response_ownership_id,length(response_id) BETWEEN 1 AND 255"` + AccountID uint64 `gorm:"not null"` + ClientKeyID uint64 `gorm:"not null"` + Provider string `gorm:"size:32;not null;check:chk_response_ownership_provider,provider IN ('grok_build','grok_web','grok_console')"` + PromptCacheKey string `gorm:"size:64;not null;default:'';check:chk_response_ownership_cache_key,length(prompt_cache_key) <= 64"` + ReasoningReplayKey string `gorm:"size:64;not null;default:'';check:chk_response_ownership_replay_key,length(reasoning_replay_key) <= 64"` + ExpiresAt time.Time `gorm:"not null;check:chk_response_ownership_expiry,expires_at > created_at"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` + Account *accountModel `gorm:"foreignKey:AccountID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + ClientKey *clientKeyModel `gorm:"foreignKey:ClientKeyID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` } func (responseOwnershipModel) TableName() string { return "response_ownership" } diff --git a/backend/internal/infra/persistence/relational/repository_test.go b/backend/internal/infra/persistence/relational/repository_test.go index 4aa6125b8..ae8233de6 100644 --- a/backend/internal/infra/persistence/relational/repository_test.go +++ b/backend/internal/infra/persistence/relational/repository_test.go @@ -435,7 +435,7 @@ func TestFreshSchemaContract(t *testing.T) { assertTableColumns(t, database, "admin_sessions", nil, []string{"revoked_at"}) assertTableColumns(t, database, "account_model_capabilities", []string{"account_id", "upstream_model"}, []string{"provider", "synced_at"}) assertTableColumns(t, database, "request_audits", []string{"media_input_images", "media_output_images", "media_output_seconds"}, nil) - assertTableColumns(t, database, "response_ownership", []string{"response_id", "account_id", "client_key_id", "provider", "expires_at"}, []string{"parent_response_id", "model_route_id"}) + assertTableColumns(t, database, "response_ownership", []string{"response_id", "account_id", "client_key_id", "provider", "prompt_cache_key", "reasoning_replay_key", "expires_at"}, []string{"parent_response_id", "model_route_id"}) var expiresNotNull int if err := database.db.Raw("SELECT `notnull` FROM pragma_table_info('account_credentials') WHERE name = 'expires_at'").Scan(&expiresNotNull).Error; err != nil { diff --git a/backend/internal/infra/persistence/relational/response_repository.go b/backend/internal/infra/persistence/relational/response_repository.go index debee9596..4a9197da9 100644 --- a/backend/internal/infra/persistence/relational/response_repository.go +++ b/backend/internal/infra/persistence/relational/response_repository.go @@ -18,6 +18,7 @@ func (r *ResponseRepository) Save(ctx context.Context, value inferencedomain.Res row := responseOwnershipModel{ ResponseID: value.ResponseID, AccountID: value.AccountID, ClientKeyID: value.ClientKeyID, Provider: string(value.Provider), + PromptCacheKey: value.PromptCacheKey, ReasoningReplayKey: value.ReasoningReplayKey, ExpiresAt: value.ExpiresAt, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, } return r.db.db.WithContext(ctx).Save(&row).Error @@ -31,6 +32,7 @@ func (r *ResponseRepository) Get(ctx context.Context, responseID string, clientK return inferencedomain.ResponseOwnership{ ResponseID: row.ResponseID, AccountID: row.AccountID, ClientKeyID: row.ClientKeyID, Provider: account.Provider(row.Provider), + PromptCacheKey: row.PromptCacheKey, ReasoningReplayKey: row.ReasoningReplayKey, ExpiresAt: row.ExpiresAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, }, nil } diff --git a/backend/internal/infra/persistence/relational/response_repository_test.go b/backend/internal/infra/persistence/relational/response_repository_test.go index 3b311a915..6593d0f4f 100644 --- a/backend/internal/infra/persistence/relational/response_repository_test.go +++ b/backend/internal/infra/persistence/relational/response_repository_test.go @@ -34,12 +34,12 @@ func TestResponseRepositoryScopesOwnershipByClientAndExpiry(t *testing.T) { t.Fatal(err) } repo := NewResponseRepository(database) - value := inferencedomain.ResponseOwnership{ResponseID: "resp_1", AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderBuild, ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now} + value := inferencedomain.ResponseOwnership{ResponseID: "resp_1", AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderBuild, PromptCacheKey: "cache-key", ReasoningReplayKey: "replay-key", ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now} if err := repo.Save(ctx, value); err != nil { t.Fatal(err) } got, err := repo.Get(ctx, value.ResponseID, value.ClientKeyID, now) - if err != nil || got.AccountID != value.AccountID || got.Provider != account.ProviderBuild { + if err != nil || got.AccountID != value.AccountID || got.Provider != account.ProviderBuild || got.PromptCacheKey != value.PromptCacheKey || got.ReasoningReplayKey != value.ReasoningReplayKey { t.Fatalf("ownership = %#v, err = %v", got, err) } if _, err := repo.Get(ctx, value.ResponseID, 99, now); !errors.Is(err, repository.ErrNotFound) { @@ -53,3 +53,65 @@ func TestResponseRepositoryScopesOwnershipByClientAndExpiry(t *testing.T) { t.Fatalf("deleted = %d, err = %v", deleted, err) } } + +func TestResponseOwnershipIdentityMigrationPreservesExistingRows(t *testing.T) { + ctx := context.Background() + database, err := OpenSQLite(ctx, filepath.Join(t.TempDir(), "responses-upgrade.db")) + if err != nil { + t.Fatal(err) + } + defer database.Close() + if err := database.InitializeSchema(ctx); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + accountValue, _, err := NewAccountRepository(database).UpsertByIdentity(ctx, account.Credential{Provider: account.ProviderBuild, Name: "legacy-owner", SourceKey: "legacy-owner", EncryptedAccessToken: testEncryptedToken, AuthStatus: account.AuthStatusActive}) + if err != nil { + t.Fatal(err) + } + keyValue, err := NewClientKeyRepository(database).Create(ctx, clientkeydomain.Key{Name: "legacy-owner", Prefix: "legacy-prefix", SecretHash: testSecretHash, EncryptedSecret: testEncryptedToken, Enabled: true, RPMLimit: 120, MaxConcurrent: 8}) + if err != nil { + t.Fatal(err) + } + repo := NewResponseRepository(database) + value := inferencedomain.ResponseOwnership{ResponseID: "resp_legacy", AccountID: accountValue.ID, ClientKeyID: keyValue.ID, Provider: account.ProviderBuild, PromptCacheKey: "old-cache", ReasoningReplayKey: "old-replay", ExpiresAt: now.Add(time.Hour), CreatedAt: now, UpdatedAt: now} + if err := repo.Save(ctx, value); err != nil { + t.Fatal(err) + } + if err := database.withSQLiteForeignKeysDisabled(ctx, func() error { + statements := []string{ + `CREATE TABLE response_ownership_legacy ( + response_id text PRIMARY KEY, + account_id integer NOT NULL, + client_key_id integer NOT NULL, + provider text NOT NULL, + expires_at datetime NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + CONSTRAINT fk_response_ownership_account FOREIGN KEY (account_id) REFERENCES provider_accounts(id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_response_ownership_client_key FOREIGN KEY (client_key_id) REFERENCES client_keys(id) ON UPDATE CASCADE ON DELETE CASCADE + )`, + `INSERT INTO response_ownership_legacy (response_id, account_id, client_key_id, provider, expires_at, created_at, updated_at) + SELECT response_id, account_id, client_key_id, provider, expires_at, created_at, updated_at FROM response_ownership`, + `DROP TABLE response_ownership`, + `ALTER TABLE response_ownership_legacy RENAME TO response_ownership`, + } + for _, statement := range statements { + if err := database.db.WithContext(ctx).Exec(statement).Error; err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatal(err) + } + + if err := database.InitializeSchema(ctx); err != nil { + t.Fatalf("upgrade response ownership identity columns: %v", err) + } + got, err := repo.Get(ctx, value.ResponseID, value.ClientKeyID, now) + if err != nil || got.AccountID != value.AccountID || got.Provider != value.Provider || got.PromptCacheKey != "" || got.ReasoningReplayKey != "" { + t.Fatalf("migrated ownership = %#v, err = %v", got, err) + } +} diff --git a/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go b/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go index bbde8dd0d..102089dbf 100644 --- a/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go +++ b/backend/internal/infra/persistence/relational/schema_console_upgrade_test.go @@ -66,6 +66,7 @@ func TestInitializeSchemaUpgradesProviderChecksForConsole(t *testing.T) { } assertSQLiteUniqueIndexes(t, database, "provider_accounts", "idx_provider_accounts_identity_key") assertSQLiteUniqueIndexes(t, database, "model_routes", "idx_model_routes_public_id", "uidx_provider_upstream") + assertTableColumns(t, database, "response_ownership", []string{"prompt_cache_key", "reasoning_replay_key"}, nil) } func assertSQLiteUniqueIndexes(t *testing.T, database *Database, table string, expected ...string) { diff --git a/backend/internal/infra/provider/cli/adapter.go b/backend/internal/infra/provider/cli/adapter.go index 318dae4a2..eadc1b76d 100644 --- a/backend/internal/infra/provider/cli/adapter.go +++ b/backend/internal/infra/provider/cli/adapter.go @@ -156,10 +156,6 @@ func (a *Adapter) ForwardResponse(ctx context.Context, request provider.Response return invalidResponsesResponse(err), nil } } - // 服务端推理回放:在 prompt_cache_key 写入后、出站前注入上一轮 encrypted items。 - if a.replay != nil && request.PromptCacheKey != "" && !isCompactPath(request.Path) && !compactionRequested { - body = a.replay.Apply(ctx, request.Model, request.PromptCacheKey, body) - } } if compactionRequested { warnings := "" @@ -171,6 +167,10 @@ func (a *Adapter) ForwardResponse(ctx context.Context, request provider.Response // 显式模式优先;auto 下仅已确认 Super 且 bot_flag_source=1 的账号默认走 XAI。 primaryBase := a.primaryBaseURL() base := a.inferenceBaseForOperation(request.Credential, request.Billing, request.Method, request.Path) + // 缓存亲和与推理回放使用不同身份。回放还必须绑定实际账号和上游平面, + // 避免把一个账号或 Build 平面签发的 opaque reasoning 发给另一作用域。 + replayBaseBody := body + body, replayKey := a.applyReasoningReplay(ctx, request, replayBaseBody, base) resp, reqURL, err := a.doResponseRequest(ctx, request, accessToken, body, base) if err != nil { return nil, err @@ -190,17 +190,18 @@ func (a *Adapter) ForwardResponse(ctx context.Context, request provider.Response primaryResp := cloneBufferedResponse(resp, primaryBody, primaryTruncated) fallbackBase := a.fallbackBaseURL() if fallbackBase != "" && !strings.EqualFold(fallbackBase, base) { - fallbackResp, fallbackURL, fallbackErr := a.doResponseRequest(ctx, request, accessToken, body, fallbackBase) + fallbackBody, fallbackReplayKey := a.applyReasoningReplay(ctx, request, replayBaseBody, fallbackBase) + fallbackResp, fallbackURL, fallbackErr := a.doResponseRequest(ctx, request, accessToken, fallbackBody, fallbackBase) if fallbackErr == nil { fallbackErr = normalizeGzipResponse(fallbackResp) } fallbackRecovered := false if fallbackErr == nil { - fallbackResp, fallbackURL, fallbackRecovered = a.recoverReasoningDecodeFailure(ctx, request, accessToken, body, fallbackBase, fallbackResp, fallbackURL) + fallbackResp, fallbackURL, fallbackRecovered = a.recoverReasoningDecodeFailure(ctx, request, accessToken, fallbackBody, fallbackBase, fallbackResp, fallbackURL) } if fallbackErr == nil && isHTTPSuccess(fallbackResp.StatusCode) { a.activateBuildAPIFallback(ctx, &request.Credential) - resp, reqURL, base = fallbackResp, fallbackURL, fallbackBase + resp, reqURL, base, body, replayKey = fallbackResp, fallbackURL, fallbackBase, fallbackBody, fallbackReplayKey reasoningRecovered = reasoningRecovered || fallbackRecovered } else { if fallbackErr == nil { @@ -215,8 +216,8 @@ func (a *Adapter) ForwardResponse(ctx context.Context, request provider.Response } modelCatalogChanged := a.modelCatalogChanged(request.Credential.ID, resp.Header.Get("x-models-etag")) // 在协议转换前捕获上游 Responses 形态,写入/清理推理回放缓存。 - if a.shouldCaptureReplay(request, resp) { - resp.Body = a.replay.CaptureBody(resp.Body, request.Model, request.PromptCacheKey, request.Streaming, isCompactPath(request.Path)) + if a.shouldCaptureReplay(request, resp, replayKey) { + resp.Body = a.replay.CaptureBody(resp.Body, request.Model, replayKey, request.Streaming, isCompactPath(request.Path)) } responsesOperation := request.Operation == "" || request.Operation == conversation.OperationResponses || request.Operation == conversation.OperationCompaction if responsesOperation && toolCompatibility != nil { @@ -291,11 +292,11 @@ func (a *Adapter) ForwardResponse(ctx context.Context, request provider.Response return &provider.Response{StatusCode: resp.StatusCode, Status: resp.Status, Header: resp.Header.Clone(), Body: resp.Body, UpstreamURL: reqURL, ModelCatalogChanged: modelCatalogChanged}, nil } -func (a *Adapter) shouldCaptureReplay(request provider.ResponseResourceRequest, resp *http.Response) bool { +func (a *Adapter) shouldCaptureReplay(request provider.ResponseResourceRequest, resp *http.Response, replayKey string) bool { if a.replay == nil || !a.replay.Enabled() || resp == nil { return false } - if request.Method != http.MethodPost || strings.TrimSpace(request.PromptCacheKey) == "" { + if request.Method != http.MethodPost || strings.TrimSpace(replayKey) == "" { return false } if resp.StatusCode < 200 || resp.StatusCode >= 300 { @@ -304,6 +305,34 @@ func (a *Adapter) shouldCaptureReplay(request provider.ResponseResourceRequest, return true } +func (a *Adapter) applyReasoningReplay(ctx context.Context, request provider.ResponseResourceRequest, body []byte, base string) ([]byte, string) { + if a.replay == nil || !a.replay.Enabled() || request.Method != http.MethodPost { + return body, "" + } + key := a.scopedReasoningReplayKey(request, base) + if key == "" { + return body, "" + } + if isCompactPath(request.Path) { + // compact 不注入历史,但成功后仍需用同一作用域清理旧 replay。 + return body, key + } + return a.replay.Apply(ctx, request.Model, key, body), key +} + +func (a *Adapter) scopedReasoningReplayKey(request provider.ResponseResourceRequest, base string) string { + seed := strings.TrimSpace(request.ReasoningReplayKey) + if seed == "" || request.Credential.ID == 0 { + return "" + } + plane := "build" + if fallback := a.fallbackBaseURL(); fallback != "" && strings.EqualFold(strings.TrimRight(base, "/"), fallback) { + plane = "xai" + } + digest := sha256.Sum256([]byte(fmt.Sprintf("grok2api:reasoning-replay:v2:%s:%d:%s", seed, request.Credential.ID, plane))) + return hex.EncodeToString(digest[:]) +} + func isCompactPath(path string) bool { return strings.Contains(strings.ToLower(path), "compact") } @@ -521,7 +550,10 @@ func (a *Adapter) GetBilling(ctx context.Context, credential account.Credential) func (a *Adapter) RefreshCredential(ctx context.Context, credential account.Credential) (provider.RefreshedCredential, error) { refreshToken, err := a.cipher.Decrypt(credential.EncryptedRefreshToken) if err != nil { - return provider.RefreshedCredential{}, &provider.CredentialRefreshError{Code: "credential_decrypt_failed", Permanent: true, Cause: err} + // 解密失败通常是本地 encryption key 临时/不匹配,属于可恢复故障; + // 不得标记为 permanent(否则密钥恢复后手动/批量刷新永远不会重试)。 + // 真正的 OAuth 永久失败(如 invalid_grant)由 oauth.refresh 返回 Permanent=true。 + return provider.RefreshedCredential{}, &provider.CredentialRefreshError{Code: "credential_decrypt_failed", Permanent: false, Cause: err} } if strings.TrimSpace(refreshToken) == "" { return provider.RefreshedCredential{}, &provider.CredentialRefreshError{Code: "missing_refresh_token", Permanent: true} @@ -575,14 +607,18 @@ func (a *Adapter) applyHeaders(req *http.Request, credential account.Credential, if trace { requestID := uuid.NewString() + // 对齐 CPA:仅在存在稳定 session 时设置 x-grok-conv-id / session-id。 + // 禁止每请求随机 UUID,否则会打散 xAI 服务器亲和,导致 cached_tokens 长期为 0。 sessionID, err := grokSessionID(promptCacheKey) if err != nil { return err } req.Header.Set("x-authenticateresponse", "authenticate-response") req.Header.Set("x-grok-agent-id", a.agentID) - req.Header.Set("x-grok-session-id", sessionID) - req.Header.Set("x-grok-conv-id", sessionID) + if sessionID != "" { + req.Header.Set("x-grok-session-id", sessionID) + req.Header.Set("x-grok-conv-id", sessionID) + } req.Header.Set("x-grok-req-id", requestID) // 网关无法从无状态 API 请求可靠恢复 CLI prompt index;该字段在 // 官方协议中可选,因此不伪造 x-grok-turn-idx。 @@ -615,19 +651,17 @@ func (a *Adapter) applyHeaders(req *http.Request, credential account.Credential, return nil } +// grokSessionID 将稳定会话键转为上游 x-grok-conv-id。 +// 空键返回空串(对齐 CPA grok_build_stays_stateless_without_session),绝不每请求随机生成。 func grokSessionID(promptCacheKey string) (string, error) { key := strings.TrimSpace(promptCacheKey) - if key != "" { - if parsed, err := uuid.Parse(key); err == nil { - return parsed.String(), nil - } - return uuid.NewHash(sha256.New(), uuid.NameSpaceURL, []byte("grok2api:session:"+key), 8).String(), nil + if key == "" { + return "", nil } - value, err := uuid.NewV7() - if err != nil { - return "", err + if parsed, err := uuid.Parse(key); err == nil { + return parsed.String(), nil } - return value.String(), nil + return uuid.NewHash(sha256.New(), uuid.NameSpaceURL, []byte("grok2api:session:"+key), 8).String(), nil } func injectPromptCacheKey(body []byte, clientKey string) ([]byte, error) { diff --git a/backend/internal/infra/provider/cli/adapter_test.go b/backend/internal/infra/provider/cli/adapter_test.go index 50137d8ce..316199f90 100644 --- a/backend/internal/infra/provider/cli/adapter_test.go +++ b/backend/internal/infra/provider/cli/adapter_test.go @@ -194,10 +194,10 @@ func TestForwardResponseReplaysReasoningAcrossMessagesTurns(t *testing.T) { }, nil }) - credential := account.Credential{Provider: account.ProviderBuild, EncryptedAccessToken: encrypted} + credential := account.Credential{ID: 7, Provider: account.ProviderBuild, EncryptedAccessToken: encrypted} first, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", - NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-replay-key", + NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-cache-key", ReasoningReplayKey: "messages-replay-key", Body: []byte(`{"model":"public","max_tokens":128,"messages":[{"role":"user","content":"first"}]}`), }) if err != nil { @@ -212,7 +212,7 @@ func TestForwardResponseReplaysReasoningAcrossMessagesTurns(t *testing.T) { second, err := adapter.ForwardResponse(context.Background(), provider.ResponseResourceRequest{ Credential: credential, Method: http.MethodPost, Path: "/responses", Model: "grok-4.5", - NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-replay-key", + NormalizeBody: true, Operation: conversation.OperationMessages, PromptCacheKey: "messages-cache-key", ReasoningReplayKey: "messages-replay-key", Body: []byte(`{"model":"public","max_tokens":128,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"first"},{"role":"user","content":"second"}]}`), }) if err != nil { @@ -227,6 +227,33 @@ func TestForwardResponseReplaysReasoningAcrossMessagesTurns(t *testing.T) { } } +func TestReasoningReplayScopeSeparatesAccountAndPlane(t *testing.T) { + adapter := NewAdapter(Config{ + BaseURL: "https://build.example/v1", + FallbackBaseURL: "https://xai.example/v1", + }, nil) + request := provider.ResponseResourceRequest{ + Credential: account.Credential{ID: 7}, + ReasoningReplayKey: "explicit-session", + } + buildKey := adapter.scopedReasoningReplayKey(request, "https://build.example/v1") + if buildKey == "" { + t.Fatal("explicit session did not produce replay scope") + } + otherAccount := request + otherAccount.Credential.ID = 8 + if got := adapter.scopedReasoningReplayKey(otherAccount, "https://build.example/v1"); got == buildKey { + t.Fatal("reasoning replay scope was shared across accounts") + } + if got := adapter.scopedReasoningReplayKey(request, "https://xai.example/v1"); got == buildKey { + t.Fatal("reasoning replay scope was shared across Build and XAI") + } + request.ReasoningReplayKey = "" + if got := adapter.scopedReasoningReplayKey(request, "https://build.example/v1"); got != "" { + t.Fatalf("soft/empty session unexpectedly enabled replay: %q", got) + } +} + func TestListModelsUsesOfficialMetadataHeaders(t *testing.T) { cipher, err := security.NewCipher(base64.StdEncoding.EncodeToString(make([]byte, 32))) if err != nil { @@ -407,13 +434,13 @@ func TestGrokSessionIDFollowsConversationIdentity(t *testing.T) { if err != nil || parsed.Version() != uuid.Version(8) || first != second { t.Fatalf("derived sessions = %q %q, %v", first, second, err) } + // 对齐 CPA:无会话键时不得伪造随机 conv-id,否则会打散 xAI 服务器亲和导致 cached_tokens=0。 generated, err := grokSessionID("") if err != nil { t.Fatal(err) } - parsed, err = uuid.Parse(generated) - if err != nil || parsed.Version() != uuid.Version(7) { - t.Fatalf("generated session = %q, %v", generated, err) + if generated != "" { + t.Fatalf("empty session key must not invent conv-id, got %q", generated) } } diff --git a/backend/internal/infra/provider/cli/session_id_test.go b/backend/internal/infra/provider/cli/session_id_test.go new file mode 100644 index 000000000..bd7edad77 --- /dev/null +++ b/backend/internal/infra/provider/cli/session_id_test.go @@ -0,0 +1,29 @@ +package cli + +import "testing" + +func TestGrokSessionIDEmptyWhenNoKey(t *testing.T) { + got, err := grokSessionID("") + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("empty key must not invent session id, got %q", got) + } + // 两次调用仍为空,证明无无随机漂移 + again, err := grokSessionID(" ") + if err != nil || again != "" { + t.Fatalf("blank key = %q err=%v", again, err) + } +} + +func TestGrokSessionIDStableForSameKey(t *testing.T) { + first, err := grokSessionID("client-session") + if err != nil || first == "" { + t.Fatalf("first = %q err=%v", first, err) + } + second, err := grokSessionID("client-session") + if err != nil || second != first { + t.Fatalf("unstable: first=%q second=%q err=%v", first, second, err) + } +} diff --git a/backend/internal/infra/provider/conversation/conversation_test.go b/backend/internal/infra/provider/conversation/conversation_test.go index 839b9a435..573621b05 100644 --- a/backend/internal/infra/provider/conversation/conversation_test.go +++ b/backend/internal/infra/provider/conversation/conversation_test.go @@ -787,3 +787,34 @@ func TestConvertResponsesStreamMessagesInputTokens(t *testing.T) { t.Fatalf("message_delta should retain upstream usage extensions:\n%s", text) } } + +func TestConvertResponsesStreamMergesPartialUsageFrames(t *testing.T) { + stream := strings.Join([]string{ + `event: response.created`, + `data: {"type":"response.created","response":{"id":"resp_usage","model":"grok-4.5","status":"in_progress","usage":{"input_tokens":120,"output_tokens":30,"total_tokens":150,"cost_in_usd_ticks":9000,"output_tokens_details":{"reasoning_tokens":12},"context_details":{"input_tokens":110,"output_tokens":25}}}}`, "", + `event: response.in_progress`, + `data: {"type":"response.in_progress","response":{"usage":{"input_tokens_details":{"cached_tokens":80}}}}`, "", + `event: response.completed`, + `data: {"type":"response.completed","response":{"id":"resp_usage","model":"grok-4.5","status":"completed"}}`, "", "", + }, "\n") + + tests := []struct { + operation string + want []string + }{ + {operation: OperationChat, want: []string{`"prompt_tokens":120`, `"completion_tokens":30`, `"cached_tokens":80`, `"reasoning_tokens":12`, `"cost_in_usd_ticks":9000`}}, + {operation: OperationMessages, want: []string{`"input_tokens":120`, `"output_tokens":30`, `"cache_read_input_tokens":80`, `"cost_in_usd_ticks":9000`}}, + } + for _, test := range tests { + converted, err := io.ReadAll(ConvertResponseStream(io.NopCloser(strings.NewReader(stream)), test.operation)) + if err != nil { + t.Fatalf("%s conversion: %v", test.operation, err) + } + text := string(converted) + for _, want := range test.want { + if !strings.Contains(text, want) { + t.Fatalf("%s partial usage lost %s:\n%s", test.operation, want, text) + } + } + } +} diff --git a/backend/internal/infra/provider/conversation/stream.go b/backend/internal/infra/provider/conversation/stream.go index 628cd6d45..e8b8fde38 100644 --- a/backend/internal/infra/provider/conversation/stream.go +++ b/backend/internal/infra/provider/conversation/stream.go @@ -344,9 +344,41 @@ func (c *streamConverter) setResponse(value responseEnvelope) { if value.CreatedAt != 0 { c.created = value.CreatedAt } - if value.Usage.InputTokens != 0 || value.Usage.OutputTokens != 0 { - c.usage = value.Usage + c.usage = mergeResponseUsage(c.usage, value.Usage) +} + +func mergeResponseUsage(current, update responseUsage) responseUsage { + if update.InputTokens != 0 { + current.InputTokens = update.InputTokens + } + if update.OutputTokens != 0 { + current.OutputTokens = update.OutputTokens + } + if update.TotalTokens != 0 { + current.TotalTokens = update.TotalTokens + } + if update.CostInUSDTicks != 0 { + current.CostInUSDTicks = update.CostInUSDTicks + } + if update.NumSourcesUsed != 0 { + current.NumSourcesUsed = update.NumSourcesUsed + } + if update.NumServerSideToolsUsed != 0 { + current.NumServerSideToolsUsed = update.NumServerSideToolsUsed + } + if update.InputTokensDetails.CachedTokens != 0 { + current.InputTokensDetails.CachedTokens = update.InputTokensDetails.CachedTokens + } + if update.OutputTokensDetails.ReasoningTokens != 0 { + current.OutputTokensDetails.ReasoningTokens = update.OutputTokensDetails.ReasoningTokens + } + if update.ContextDetails.InputTokens != 0 { + current.ContextDetails.InputTokens = update.ContextDetails.InputTokens + } + if update.ContextDetails.OutputTokens != 0 { + current.ContextDetails.OutputTokens = update.ContextDetails.OutputTokens } + return current } func (c *streamConverter) start() error { diff --git a/backend/internal/infra/provider/provider.go b/backend/internal/infra/provider/provider.go index d2dae6480..8cc45489a 100644 --- a/backend/internal/infra/provider/provider.go +++ b/backend/internal/infra/provider/provider.go @@ -120,10 +120,12 @@ type ResponseResourceRequest struct { Body []byte Model string PromptCacheKey string - IdempotencyID string - Streaming bool - NormalizeBody bool - Operation string + // ReasoningReplayKey 只来自客户端显式会话身份;soft cache identity 不得用于密文回放。 + ReasoningReplayKey string + IdempotencyID string + Streaming bool + NormalizeBody bool + Operation string } // Response 表示尚未写入下游的上游响应。 diff --git a/backend/internal/transport/http/inference/handler.go b/backend/internal/transport/http/inference/handler.go index 986ff8534..6fe364be0 100644 --- a/backend/internal/transport/http/inference/handler.go +++ b/backend/internal/transport/http/inference/handler.go @@ -1048,11 +1048,11 @@ func (i *responseInspector) Inspect(chunk []byte) { i.observeTerminal(value) if !bytes.Equal(value, []byte("[DONE]")) { metadata := extractMetadata(value) - if metadata.Usage.TotalTokens > 0 { + if hasUsageSignal(metadata.Usage) { if metadata.Usage.ResponseModel == "" { metadata.Usage.ResponseModel = i.metadata.Model } - i.metadata.Usage = metadata.Usage + i.metadata.Usage = mergeGatewayUsage(i.metadata.Usage, metadata.Usage) } if metadata.ResponseID != "" { i.metadata.ResponseID = metadata.ResponseID @@ -1161,20 +1161,28 @@ type responsePayloadDTO struct { } type responseUsageDTO struct { - InputTokens int64 `json:"input_tokens"` - InputTokensCamel int64 `json:"inputTokens"` - OutputTokens int64 `json:"output_tokens"` - OutputTokensCamel int64 `json:"outputTokens"` - TotalTokens int64 `json:"total_tokens"` - TotalTokensCamel int64 `json:"totalTokens"` - CostInUSDTicks int64 `json:"cost_in_usd_ticks"` - NumSourcesUsed int64 `json:"num_sources_used"` - NumServerSideToolsUsed int64 `json:"num_server_side_tools_used"` - InputTokensDetails responseInputDetailsDTO `json:"input_tokens_details"` - OutputTokensDetails responseOutputDetailsDTO `json:"output_tokens_details"` - ContextDetails responseContextDetailsDTO `json:"context_details"` - PromptTokens int64 `json:"prompt_tokens"` - CompletionTokens int64 `json:"completion_tokens"` + InputTokens int64 `json:"input_tokens"` + InputTokensCamel int64 `json:"inputTokens"` + OutputTokens int64 `json:"output_tokens"` + OutputTokensCamel int64 `json:"outputTokens"` + TotalTokens int64 `json:"total_tokens"` + TotalTokensCamel int64 `json:"totalTokens"` + CostInUSDTicks int64 `json:"cost_in_usd_ticks"` + NumSourcesUsed int64 `json:"num_sources_used"` + NumServerSideToolsUsed int64 `json:"num_server_side_tools_used"` + // Responses 协议:input_tokens_details.cached_tokens + InputTokensDetails responseInputDetailsDTO `json:"input_tokens_details"` + // OpenAI Chat Completions 协议:prompt_tokens_details.cached_tokens + PromptTokensDetails responseInputDetailsDTO `json:"prompt_tokens_details"` + // Anthropic Messages 协议:顶层 cache_read_input_tokens + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + OutputTokensDetails responseOutputDetailsDTO `json:"output_tokens_details"` + // OpenAI Chat Completions 协议:completion_tokens_details.reasoning_tokens + CompletionTokensDetails responseOutputDetailsDTO `json:"completion_tokens_details"` + ContextDetails responseContextDetailsDTO `json:"context_details"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` } type responseInputDetailsDTO struct { @@ -1212,9 +1220,21 @@ func (value responseUsageDTO) toGatewayUsage(responseModel string) gateway.Usage if total == 0 { total = input + output } + // 统一缓存命中:Responses / Chat Completions / Anthropic Messages + cached := value.InputTokensDetails.CachedTokens + if cached == 0 { + cached = value.PromptTokensDetails.CachedTokens + } + if cached == 0 { + cached = value.CacheReadInputTokens + } + reasoning := value.OutputTokensDetails.ReasoningTokens + if reasoning == 0 { + reasoning = value.CompletionTokensDetails.ReasoningTokens + } return gateway.Usage{ - InputTokens: input, CachedInputTokens: value.InputTokensDetails.CachedTokens, - OutputTokens: output, ReasoningTokens: value.OutputTokensDetails.ReasoningTokens, + InputTokens: input, CachedInputTokens: cached, + OutputTokens: output, ReasoningTokens: reasoning, TotalTokens: total, CostInUSDTicks: value.CostInUSDTicks, NumSourcesUsed: value.NumSourcesUsed, NumServerSideToolsUsed: value.NumServerSideToolsUsed, ContextInputTokens: value.ContextDetails.InputTokens, ContextOutputTokens: value.ContextDetails.OutputTokens, @@ -1222,6 +1242,54 @@ func (value responseUsageDTO) toGatewayUsage(responseModel string) gateway.Usage } } +func hasUsageSignal(usage gateway.Usage) bool { + return usage.InputTokens > 0 || usage.OutputTokens > 0 || usage.TotalTokens > 0 || + usage.CachedInputTokens > 0 || usage.ReasoningTokens > 0 || usage.CostInUSDTicks > 0 || + usage.NumSourcesUsed > 0 || usage.NumServerSideToolsUsed > 0 || + usage.ContextInputTokens > 0 || usage.ContextOutputTokens > 0 +} + +// mergeGatewayUsage 合并流式多帧 usage:非零字段覆盖,避免后到半截帧抹掉已解析缓存命中。 +func mergeGatewayUsage(base, next gateway.Usage) gateway.Usage { + if next.InputTokens > 0 { + base.InputTokens = next.InputTokens + } + if next.OutputTokens > 0 { + base.OutputTokens = next.OutputTokens + } + if next.TotalTokens > 0 { + base.TotalTokens = next.TotalTokens + } + if next.CachedInputTokens > 0 { + base.CachedInputTokens = next.CachedInputTokens + } + if next.ReasoningTokens > 0 { + base.ReasoningTokens = next.ReasoningTokens + } + if next.CostInUSDTicks > 0 { + base.CostInUSDTicks = next.CostInUSDTicks + } + if next.NumSourcesUsed > 0 { + base.NumSourcesUsed = next.NumSourcesUsed + } + if next.NumServerSideToolsUsed > 0 { + base.NumServerSideToolsUsed = next.NumServerSideToolsUsed + } + if next.ContextInputTokens > 0 { + base.ContextInputTokens = next.ContextInputTokens + } + if next.ContextOutputTokens > 0 { + base.ContextOutputTokens = next.ContextOutputTokens + } + if next.ResponseModel != "" { + base.ResponseModel = next.ResponseModel + } + if base.TotalTokens == 0 && (base.InputTokens > 0 || base.OutputTokens > 0) { + base.TotalTokens = base.InputTokens + base.OutputTokens + } + return base +} + func copyHeaders(destination, source http.Header) { excluded := map[string]struct{}{ "connection": {}, "content-length": {}, "keep-alive": {}, "proxy-authenticate": {}, diff --git a/backend/internal/transport/http/inference/handler_test.go b/backend/internal/transport/http/inference/handler_test.go index df9df8677..f27fcd97b 100644 --- a/backend/internal/transport/http/inference/handler_test.go +++ b/backend/internal/transport/http/inference/handler_test.go @@ -489,6 +489,54 @@ func TestExtractUsageFromCompletedEvent(t *testing.T) { } } +func TestExtractUsageFromAnthropicMessagesCaches(t *testing.T) { + // Anthropic Messages 协议用 cache_read_input_tokens,不得再记为 0。 + metadata := extractMetadata([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"grok-4.5","usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":80,"cost_in_usd_ticks":1000}}`)) + if metadata.Usage.CachedInputTokens != 80 || metadata.Usage.InputTokens != 100 || metadata.Usage.OutputTokens != 20 { + t.Fatalf("anthropic usage = %#v", metadata.Usage) + } +} + +func TestExtractUsageFromChatCompletionsCaches(t *testing.T) { + // OpenAI Chat Completions 用 prompt_tokens_details.cached_tokens。 + metadata := extractMetadata([]byte(`{"id":"chatcmpl_1","object":"chat.completion","model":"grok-4.5","usage":{"prompt_tokens":50,"completion_tokens":10,"total_tokens":60,"prompt_tokens_details":{"cached_tokens":30},"completion_tokens_details":{"reasoning_tokens":5}}}`)) + if metadata.Usage.CachedInputTokens != 30 || metadata.Usage.InputTokens != 50 || metadata.Usage.OutputTokens != 10 || metadata.Usage.ReasoningTokens != 5 || metadata.Usage.TotalTokens != 60 { + t.Fatalf("chat usage = %#v", metadata.Usage) + } +} + +func TestExtractUsagePrefersResponsesCachedTokensOverAnthropicField(t *testing.T) { + // 同时存在时优先 Responses 字段(正常路径不会并存,防回归)。 + metadata := extractMetadata([]byte(`{"usage":{"input_tokens":10,"output_tokens":1,"input_tokens_details":{"cached_tokens":7},"cache_read_input_tokens":99}}`)) + if metadata.Usage.CachedInputTokens != 7 { + t.Fatalf("prefer responses cached = %#v", metadata.Usage) + } +} + +func TestStreamInspectorMergesCachedTokensAcrossFrames(t *testing.T) { + // 模拟流式:先到 input/output,后到带 cache 的 usage 帧。 + inspector := &responseInspector{protocol: streamProtocolAnthropic} + inspector.Inspect([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"input_tokens\":100,\"output_tokens\":20}}\n\n")) + inspector.Inspect([]byte("data: {\"type\":\"message_delta\",\"usage\":{\"cache_read_input_tokens\":80}}\n\n")) + inspector.Inspect([]byte("data: {\"type\":\"message_stop\"}\n\n")) + inspector.Finish() + usage := inspector.Metadata().Usage + if usage.InputTokens != 100 || usage.OutputTokens != 20 || usage.CachedInputTokens != 80 { + t.Fatalf("merged stream usage = %#v", usage) + } +} + +func TestStreamInspectorAcceptsChatCachedOnlyFrame(t *testing.T) { + inspector := &responseInspector{protocol: streamProtocolChat} + inspector.Inspect([]byte("data: {\"usage\":{\"prompt_tokens\":40,\"completion_tokens\":5,\"total_tokens\":45,\"prompt_tokens_details\":{\"cached_tokens\":25}}}\n\n")) + inspector.Inspect([]byte("data: [DONE]\n\n")) + inspector.Finish() + usage := inspector.Metadata().Usage + if usage.CachedInputTokens != 25 || usage.InputTokens != 40 || usage.TotalTokens != 45 { + t.Fatalf("chat stream cached usage = %#v", usage) + } +} + func TestUsageInspectorHandlesChunkedSSE(t *testing.T) { inspector := &responseInspector{} inspector.Inspect([]byte("data: {\"response\":{\"id\":\"resp_stream\",\"usage\":{\"input_tokens\":2,")) diff --git a/backend/internal/transport/http/inference/prompt_cache.go b/backend/internal/transport/http/inference/prompt_cache.go index 8669a4fe7..5469c1559 100644 --- a/backend/internal/transport/http/inference/prompt_cache.go +++ b/backend/internal/transport/http/inference/prompt_cache.go @@ -9,27 +9,42 @@ import ( const maxPromptCacheSeedBytes = 1024 // extractPromptCacheSeed 提取客户端会话标识;真正发往上游的 key 会在 Gateway 中隔离并哈希。 +// 兼容 Claude Code / Codex / Sub2API(session_id、conversation_id、prompt_cache_key)。 func extractPromptCacheSeed(headers http.Header, body []byte) string { - if seed := normalizePromptCacheSeed(headers.Get("X-Claude-Code-Session-Id")); seed != "" { - return seed - } - for _, name := range []string{"X-Session-ID", "Session-Id", "Session_id"} { - if seed := normalizePromptCacheSeed(headers.Get(name)); seed != "" { - return seed + if headers != nil { + // 优先级对齐 CPA + Sub2API 常用信号 + for _, name := range []string{ + "X-Claude-Code-Session-Id", + "X-Session-ID", "X-Session-Id", "Session-Id", "Session_id", "session_id", + "X-Conversation-Id", "Conversation-Id", "Conversation_id", "conversation_id", + // Sub2API / 反代偶发透传 + "X-Client-Session-Id", "X-Grok-Conv-Id", "x-grok-conv-id", + } { + if seed := normalizePromptCacheSeed(headers.Get(name)); seed != "" { + return seed + } } } var payload struct { - Metadata struct { + PromptCacheKey string `json:"prompt_cache_key"` + ConversationID string `json:"conversation_id"` + ConversationIDCamel string `json:"conversationId"` + SessionID string `json:"session_id"` + SessionIDCamel string `json:"sessionId"` + Metadata struct { SessionID string `json:"session_id"` SessionIDCamel string `json:"sessionId"` UserID string `json:"user_id"` } `json:"metadata"` - ConversationID string `json:"conversation_id"` - ConversationIDCamel string `json:"conversationId"` } if json.Unmarshal(body, &payload) != nil { return "" } + // body.prompt_cache_key 在 handler 里也会进 PromptCacheKey;这里再提取一次, + // 保证仅依赖 seed 路径的中间件/日志也能看到。 + if seed := normalizePromptCacheSeed(payload.PromptCacheKey); seed != "" { + return seed + } if seed := normalizePromptCacheSeed(payload.Metadata.SessionID); seed != "" { return seed } @@ -39,6 +54,12 @@ func extractPromptCacheSeed(headers http.Header, body []byte) string { if seed := promptCacheSeedFromUserID(payload.Metadata.UserID); seed != "" { return seed } + if seed := normalizePromptCacheSeed(payload.SessionID); seed != "" { + return seed + } + if seed := normalizePromptCacheSeed(payload.SessionIDCamel); seed != "" { + return seed + } if seed := normalizePromptCacheSeed(payload.ConversationID); seed != "" { return seed } diff --git a/backend/internal/transport/http/inference/prompt_cache_test.go b/backend/internal/transport/http/inference/prompt_cache_test.go index 032a36c47..ec0041ade 100644 --- a/backend/internal/transport/http/inference/prompt_cache_test.go +++ b/backend/internal/transport/http/inference/prompt_cache_test.go @@ -23,6 +23,10 @@ func TestExtractPromptCacheSeedSupportsClaudeCodeForms(t *testing.T) { {name: "suffix user id", body: `{"metadata":{"user_id":"user_account_session_123e4567-e89b-12d3-a456-426614174000"}}`, want: "123e4567-e89b-12d3-a456-426614174000"}, {name: "conversation snake case", body: `{"conversation_id":"conversation-session"}`, want: "conversation-session"}, {name: "conversation camel case", body: `{"conversationId":"camel-conversation"}`, want: "camel-conversation"}, + {name: "body prompt_cache_key", body: `{"prompt_cache_key":"sub2api-session"}`, want: "sub2api-session"}, + // http.Header canonical key is Session-Id for "session_id" + {name: "sub2api session_id via Set", headers: func() http.Header { h := make(http.Header); h.Set("session_id", "sub2-header-session"); return h }(), want: "sub2-header-session"}, + {name: "grok conv header", headers: http.Header{"X-Grok-Conv-Id": {"grok-conv-session"}}, want: "grok-conv-session"}, {name: "per request id ignored", headers: http.Header{"X-Client-Request-Id": {"request-123"}}, want: ""}, {name: "ordinary user id", body: `{"metadata":{"user_id":"user-123"}}`, want: ""}, }