فهرست منبع

fix: address metadata user_id review feedback

ding113 3 هفته پیش
والد
کامیت
7addf4612d

+ 5 - 13
src/app/v1/_lib/proxy/forwarder.ts

@@ -10,7 +10,10 @@ import {
   recordFailure,
   recordSuccess,
 } from "@/lib/circuit-breaker";
-import { injectClaudeMetadataUserIdWithContext } from "@/lib/claude-code/metadata-user-id";
+import {
+  hasUsableClaudeMetadataUserId,
+  injectClaudeMetadataUserIdWithContext,
+} from "@/lib/claude-code/metadata-user-id";
 import { applyCodexProviderOverridesWithAudit } from "@/lib/codex/provider-overrides";
 import { getCachedSystemSettings, isHttp2Enabled } from "@/lib/config";
 import { getEnvConfig } from "@/lib/config/env.schema";
@@ -362,17 +365,6 @@ export function injectClaudeMetadataUserId(
   message: Record<string, unknown>,
   session: ProxySession
 ): Record<string, unknown> {
-  const existingMetadata =
-    typeof message.metadata === "object" && message.metadata !== null
-      ? (message.metadata as Record<string, unknown>)
-      : undefined;
-
-  // 检查是否已存在 metadata.user_id
-  if (existingMetadata?.user_id !== undefined && existingMetadata?.user_id !== null) {
-    return message;
-  }
-
-  // 获取必要信息
   const keyId = session.authState?.key?.id;
   const sessionId = session.sessionId;
 
@@ -407,7 +399,7 @@ function applyClaudeMetadataUserIdInjectionWithAudit(
       ? (message.metadata as Record<string, unknown>)
       : undefined;
 
-  if (existingMetadata?.user_id !== undefined && existingMetadata?.user_id !== null) {
+  if (hasUsableClaudeMetadataUserId(existingMetadata?.user_id)) {
     logger.info("[ProxyForwarder] Claude metadata.user_id injection skipped", {
       enabled,
       hit: false,

+ 35 - 14
src/app/v1/_lib/proxy/session-guard.ts

@@ -79,17 +79,26 @@ export class ProxySessionGuard {
         }
       }
 
-      if (
+      const warmupMaybeIntercepted =
+        session.isWarmupRequest() &&
+        !!session.authState?.success &&
+        !!session.authState.user &&
+        !!session.authState.key &&
+        !!session.authState.apiKey &&
+        systemSettings.interceptAnthropicWarmupRequests;
+
+      const extractedClaudeSessionId =
         claudeMetadataCompletionEnabled &&
+        !warmupMaybeIntercepted &&
         session.originalFormat === "claude" &&
         !isCodexRequest
-      ) {
-        const completionSessionId =
-          SessionManager.extractClientSessionId(requestMessage, null, session.userAgent) ??
-          SessionManager.generateSessionId();
+          ? SessionManager.extractClientSessionId(requestMessage, null, session.userAgent)
+          : null;
+
+      if (extractedClaudeSessionId) {
         const completedMessage = injectClaudeMetadataUserIdWithContext(requestMessage, {
           keyId,
-          sessionId: completionSessionId,
+          sessionId: extractedClaudeSessionId,
           userAgent: session.userAgent,
         });
 
@@ -98,14 +107,6 @@ export class ProxySessionGuard {
         }
       }
 
-      const warmupMaybeIntercepted =
-        session.isWarmupRequest() &&
-        !!session.authState?.success &&
-        !!session.authState.user &&
-        !!session.authState.key &&
-        !!session.authState.apiKey &&
-        systemSettings.interceptAnthropicWarmupRequests;
-
       // 1. 尝试从客户端提取 session_id(兼容 metadata.user_id / metadata.session_id)
       const clientSessionId = SessionManager.extractClientSessionId(
         session.request.message,
@@ -122,6 +123,26 @@ export class ProxySessionGuard {
       // 4. 设置到 session 对象
       session.setSessionId(sessionId);
 
+      if (
+        claudeMetadataCompletionEnabled &&
+        !warmupMaybeIntercepted &&
+        session.originalFormat === "claude" &&
+        !isCodexRequest
+      ) {
+        const completedMessage = injectClaudeMetadataUserIdWithContext(
+          session.request.message as Record<string, unknown>,
+          {
+            keyId,
+            sessionId,
+            userAgent: session.userAgent,
+          }
+        );
+
+        if (completedMessage !== session.request.message) {
+          session.request.message = completedMessage;
+        }
+      }
+
       // 4.1 获取并设置请求序号(Session 内唯一标识每个请求)
       const requestSequence = await SessionManager.getNextRequestSequence(sessionId);
       session.setRequestSequence(requestSequence);

+ 50 - 2
src/lib/claude-code/metadata-user-id.test.ts

@@ -6,7 +6,7 @@ import {
   injectClaudeMetadataUserIdWithContext,
   parseClaudeMetadataUserId,
   resolveClaudeMetadataUserIdFormat,
-} from "./metadata-user-id";
+} from "@/lib/claude-code/metadata-user-id";
 
 describe("Claude metadata.user_id helper", () => {
   test("解析旧格式 user_id 时应提取 sessionId 和 deviceId", () => {
@@ -36,6 +36,33 @@ describe("Claude metadata.user_id helper", () => {
     });
   });
 
+  test("解析 JSON 字符串 user_id 时应 trim sessionId", () => {
+    const userId = JSON.stringify({
+      device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+      account_uuid: "",
+      session_id: "  sess_json_trimmed  ",
+    });
+
+    expect(parseClaudeMetadataUserId(userId)).toEqual({
+      sessionId: "sess_json_trimmed",
+      format: "json",
+      deviceId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+      accountUuid: "",
+    });
+  });
+
+  test("解析旧格式 user_id 时应 trim sessionId", () => {
+    const userId =
+      "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_  sess_legacy_trimmed  ";
+
+    expect(parseClaudeMetadataUserId(userId)).toEqual({
+      sessionId: "sess_legacy_trimmed",
+      format: "legacy",
+      deviceId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      accountUuid: null,
+    });
+  });
+
   test("无法解析的 user_id 应返回空结果", () => {
     expect(parseClaudeMetadataUserId("not-a-valid-user-id")).toEqual({
       sessionId: null,
@@ -98,7 +125,7 @@ describe("Claude metadata.user_id helper", () => {
     });
   });
 
-  test("注入时应保留有 metadata.user_id", () => {
+  test("注入时应保留有 metadata.user_id", () => {
     const message = {
       metadata: {
         user_id: "existing_user_id",
@@ -114,4 +141,25 @@ describe("Claude metadata.user_id helper", () => {
       })
     ).toBe(message);
   });
+
+  test("注入时遇到空白 metadata.user_id 应继续补全", () => {
+    const message = {
+      metadata: {
+        user_id: "   ",
+      },
+    };
+
+    const result = injectClaudeMetadataUserIdWithContext(message, {
+      keyId: 1,
+      sessionId: "sess_fill_blank",
+      userAgent: "claude-cli/2.1.78 (external, cli)",
+    });
+
+    expect(result).not.toBe(message);
+    expect(JSON.parse((result.metadata as Record<string, unknown>).user_id as string)).toEqual({
+      device_id: buildClaudeMetadataDeviceId(1),
+      account_uuid: "",
+      session_id: "sess_fill_blank",
+    });
+  });
 });

+ 12 - 5
src/lib/claude-code/metadata-user-id.ts

@@ -41,6 +41,14 @@ export function buildClaudeMetadataDeviceId(keyId: number): string {
   return crypto.createHash("sha256").update(`claude_user_${keyId}`).digest("hex");
 }
 
+export function hasUsableClaudeMetadataUserId(userId: unknown): boolean {
+  if (typeof userId === "string") {
+    return userId.trim().length > 0;
+  }
+
+  return userId !== undefined && userId !== null;
+}
+
 export function resolveClaudeMetadataUserIdFormat(
   userAgent?: string | null
 ): ClaudeMetadataUserIdFormat {
@@ -69,9 +77,7 @@ export function parseClaudeMetadataUserId(userId: unknown): ClaudeMetadataUserId
     if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
       const parsedObj = parsed as Record<string, unknown>;
       const sessionId =
-        typeof parsedObj.session_id === "string" && parsedObj.session_id.trim().length > 0
-          ? parsedObj.session_id
-          : null;
+        typeof parsedObj.session_id === "string" ? parsedObj.session_id.trim() : null;
 
       if (sessionId) {
         return {
@@ -91,7 +97,8 @@ export function parseClaudeMetadataUserId(userId: unknown): ClaudeMetadataUserId
     return emptyParseResult();
   }
 
-  const [, deviceId, sessionId] = legacyMatch;
+  const [, deviceId, rawSessionId] = legacyMatch;
+  const sessionId = rawSessionId?.trim();
   if (!sessionId) {
     return emptyParseResult();
   }
@@ -128,7 +135,7 @@ export function injectClaudeMetadataUserIdWithContext(
       ? (message.metadata as Record<string, unknown>)
       : undefined;
 
-  if (existingMetadata?.user_id !== undefined && existingMetadata?.user_id !== null) {
+  if (hasUsableClaudeMetadataUserId(existingMetadata?.user_id)) {
     return message;
   }
 

+ 5 - 3
tests/unit/proxy/metadata-injection.test.ts

@@ -72,7 +72,7 @@ describe("injectClaudeMetadataUserId", () => {
     expect((result.metadata as Record<string, unknown>).user_id).toBe("existing_user_id");
   });
 
-  it("metadata.user_id 为空字符串时应保持原样不注入", () => {
+  it("metadata.user_id 为空字符串时应继续补全", () => {
     const message: Record<string, unknown> = {
       metadata: {
         user_id: "",
@@ -82,8 +82,10 @@ describe("injectClaudeMetadataUserId", () => {
 
     const result = injectClaudeMetadataUserId(message, session);
 
-    expect(result).toBe(message);
-    expect((result.metadata as Record<string, unknown>).user_id).toBe("");
+    expect(result).not.toBe(message);
+    expect((result.metadata as Record<string, unknown>).user_id).toMatch(
+      /^user_[a-f0-9]{64}_account__session_sess_abc123$/
+    );
   });
 
   it("keyId 缺失时应跳过注入并返回原始 message", () => {

+ 26 - 5
tests/unit/proxy/session-guard-warmup-intercept.test.ts

@@ -101,7 +101,10 @@ beforeEach(() => {
   extractClientSessionIdMock.mockReturnValue(null);
   getOrCreateSessionIdMock.mockResolvedValue("session_assigned");
   getNextRequestSequenceMock.mockResolvedValue(1);
-  getCachedSystemSettingsMock.mockResolvedValue({ interceptAnthropicWarmupRequests: true });
+  getCachedSystemSettingsMock.mockResolvedValue({
+    interceptAnthropicWarmupRequests: true,
+    enableClaudeMetadataUserIdInjection: true,
+  });
 });
 
 describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
@@ -157,6 +160,7 @@ describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
         },
         model: "claude-sonnet-4-5-20250929",
       },
+      isWarmupRequest: () => false,
     });
 
     await ProxySessionGuard.ensure(session);
@@ -167,9 +171,8 @@ describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
     expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], "sess_legacy_seed");
   });
 
-  test("Claude 无法获取版本且缺少 session 标识时,应生成 JSON user_id 供后续链路复用", async () => {
+  test("Claude 无客户端 session 时,不应预生成 session 写回请求体,而应回填已分配 session", async () => {
     const ProxySessionGuard = await loadGuard();
-    generateSessionIdMock.mockReturnValue("sess_generated_by_guard");
     extractClientSessionIdMock.mockImplementation((requestMessage: Record<string, unknown>) => {
       const metadata =
         requestMessage.metadata && typeof requestMessage.metadata === "object"
@@ -194,6 +197,7 @@ describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
         message: {},
         model: "claude-sonnet-4-5-20250929",
       },
+      isWarmupRequest: () => false,
     });
 
     await ProxySessionGuard.ensure(session);
@@ -203,8 +207,25 @@ describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
     ).toEqual({
       device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
       account_uuid: "",
-      session_id: "sess_generated_by_guard",
+      session_id: "session_assigned",
     });
-    expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], "sess_generated_by_guard");
+    expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], null);
+    expect(generateSessionIdMock).not.toHaveBeenCalled();
+  });
+
+  test("当 warmup 请求会被拦截时,不应补全 Claude metadata.user_id", async () => {
+    const ProxySessionGuard = await loadGuard();
+    const session = createMockSession({
+      userAgent: "claude-cli/2.1.78 (external, cli)",
+      request: {
+        message: {},
+        model: "claude-sonnet-4-5-20250929",
+      },
+      isWarmupRequest: () => true,
+    });
+
+    await ProxySessionGuard.ensure(session);
+
+    expect((session.request.message as Record<string, unknown>).metadata).toBeUndefined();
   });
 });