Просмотр исходного кода

fix: support Claude Code metadata user_id JSON format

ding113 3 недель назад
Родитель
Сommit
86e7bf73bf

+ 9 - 24
src/app/v1/_lib/proxy/forwarder.ts

@@ -1,4 +1,3 @@
-import crypto from "node:crypto";
 import { STATUS_CODES } from "node:http";
 import type { Readable } from "node:stream";
 import { createGunzip, constants as zlibConstants } from "node:zlib";
@@ -11,6 +10,7 @@ import {
   recordFailure,
   recordSuccess,
 } from "@/lib/circuit-breaker";
+import { 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";
@@ -351,9 +351,9 @@ async function persistSpecialSettings(session: ProxySession): Promise<void> {
 /**
  * 为 Claude 请求注入 metadata.user_id
  *
- * 格式:user_{stableHash}_account__session_{sessionId}
- * - stableHash: 基于 API Key ID 生成的稳定哈希(64位 hex),生成后保持不变
- * - sessionId: 当前请求的 session ID
+ * 格式选择
+ * - Claude Code < v2.1.78: `user_{stableHash}_account__session_{sessionId}`
+ * - 无法识别版本 / >= v2.1.78: JSON 字符串 `{"device_id":"...","account_uuid":"","session_id":"..."}`
  *
  * 注意:如果请求体中已存在 metadata.user_id,则保持原样不修改
  * @internal
@@ -376,26 +376,11 @@ export function injectClaudeMetadataUserId(
   const keyId = session.authState?.key?.id;
   const sessionId = session.sessionId;
 
-  if (keyId == null || !sessionId) {
-    return message;
-  }
-
-  // 生成稳定的 user hash(基于 API Key ID)
-  const stableHash = crypto.createHash("sha256").update(`claude_user_${keyId}`).digest("hex");
-
-  // 构建 user_id
-  const userId = `user_${stableHash}_account__session_${sessionId}`;
-
-  // 注入 metadata
-  const newMetadata = {
-    ...existingMetadata,
-    user_id: userId,
-  };
-
-  return {
-    ...message,
-    metadata: newMetadata,
-  };
+  return injectClaudeMetadataUserIdWithContext(message, {
+    keyId,
+    sessionId,
+    userAgent: session.userAgent,
+  });
 }
 
 function applyClaudeMetadataUserIdInjectionWithAudit(

+ 23 - 1
src/app/v1/_lib/proxy/session-guard.ts

@@ -1,3 +1,4 @@
+import { injectClaudeMetadataUserIdWithContext } from "@/lib/claude-code/metadata-user-id";
 import { getCachedSystemSettings } from "@/lib/config";
 import { logger } from "@/lib/logger";
 import { resolveKeyUserConcurrentSessionLimits } from "@/lib/rate-limit/concurrent-session-limit";
@@ -53,6 +54,8 @@ export class ProxySessionGuard {
 
       // Codex Session ID 补全:在提取 clientSessionId 之前触发,避免落入不稳定的降级方案
       const codexCompletionEnabled = systemSettings.enableCodexSessionIdCompletion ?? true;
+      const claudeMetadataCompletionEnabled =
+        systemSettings.enableClaudeMetadataUserIdInjection ?? true;
       const requestMessage = session.request.message as Record<string, unknown>;
       const isCodexRequest = Array.isArray(requestMessage.input);
 
@@ -76,6 +79,25 @@ export class ProxySessionGuard {
         }
       }
 
+      if (
+        claudeMetadataCompletionEnabled &&
+        session.originalFormat === "claude" &&
+        !isCodexRequest
+      ) {
+        const completionSessionId =
+          SessionManager.extractClientSessionId(requestMessage, null, session.userAgent) ??
+          SessionManager.generateSessionId();
+        const completedMessage = injectClaudeMetadataUserIdWithContext(requestMessage, {
+          keyId,
+          sessionId: completionSessionId,
+          userAgent: session.userAgent,
+        });
+
+        if (completedMessage !== requestMessage) {
+          session.request.message = completedMessage;
+        }
+      }
+
       const warmupMaybeIntercepted =
         session.isWarmupRequest() &&
         !!session.authState?.success &&
@@ -84,7 +106,7 @@ export class ProxySessionGuard {
         !!session.authState.apiKey &&
         systemSettings.interceptAnthropicWarmupRequests;
 
-      // 1. 尝试从客户端提取 session_id(metadata.session_id)
+      // 1. 尝试从客户端提取 session_id(兼容 metadata.user_id / metadata.session_id)
       const clientSessionId = SessionManager.extractClientSessionId(
         session.request.message,
         session.headers,

+ 117 - 0
src/lib/claude-code/metadata-user-id.test.ts

@@ -0,0 +1,117 @@
+import { describe, expect, test } from "vitest";
+import {
+  buildClaudeMetadataDeviceId,
+  buildClaudeMetadataUserId,
+  CLAUDE_CODE_METADATA_USER_ID_JSON_SWITCH_VERSION,
+  injectClaudeMetadataUserIdWithContext,
+  parseClaudeMetadataUserId,
+  resolveClaudeMetadataUserIdFormat,
+} from "./metadata-user-id";
+
+describe("Claude metadata.user_id helper", () => {
+  test("解析旧格式 user_id 时应提取 sessionId 和 deviceId", () => {
+    const userId =
+      "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_sess_legacy_123";
+
+    expect(parseClaudeMetadataUserId(userId)).toEqual({
+      sessionId: "sess_legacy_123",
+      format: "legacy",
+      deviceId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      accountUuid: null,
+    });
+  });
+
+  test("解析 JSON 字符串 user_id 时应提取 sessionId 和 deviceId", () => {
+    const userId = JSON.stringify({
+      device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+      account_uuid: "",
+      session_id: "sess_json_123",
+    });
+
+    expect(parseClaudeMetadataUserId(userId)).toEqual({
+      sessionId: "sess_json_123",
+      format: "json",
+      deviceId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+      accountUuid: "",
+    });
+  });
+
+  test("无法解析的 user_id 应返回空结果", () => {
+    expect(parseClaudeMetadataUserId("not-a-valid-user-id")).toEqual({
+      sessionId: null,
+      format: null,
+      deviceId: null,
+      accountUuid: null,
+    });
+  });
+
+  test("低于切换版本的 Claude Code 客户端应使用旧格式", () => {
+    const keyId = 42;
+    const sessionId = "sess_old_format";
+
+    expect(resolveClaudeMetadataUserIdFormat("claude-cli/2.1.77 (external, cli)")).toBe("legacy");
+    expect(
+      buildClaudeMetadataUserId({
+        keyId,
+        sessionId,
+        userAgent: "claude-cli/2.1.77 (external, cli)",
+      })
+    ).toBe(`user_${buildClaudeMetadataDeviceId(keyId)}_account__session_${sessionId}`);
+  });
+
+  test(`版本为 ${CLAUDE_CODE_METADATA_USER_ID_JSON_SWITCH_VERSION} 的客户端应使用 JSON 字符串`, () => {
+    const keyId = 42;
+    const sessionId = "sess_json_format";
+
+    expect(resolveClaudeMetadataUserIdFormat("claude-cli/2.1.78 (external, cli)")).toBe("json");
+    expect(
+      JSON.parse(
+        buildClaudeMetadataUserId({
+          keyId,
+          sessionId,
+          userAgent: "claude-cli/2.1.78 (external, cli)",
+        })
+      )
+    ).toEqual({
+      device_id: buildClaudeMetadataDeviceId(keyId),
+      account_uuid: "",
+      session_id: sessionId,
+    });
+  });
+
+  test("无法获取版本时应默认使用 JSON 字符串", () => {
+    const keyId = 42;
+    const sessionId = "sess_unknown_version";
+
+    expect(resolveClaudeMetadataUserIdFormat(undefined)).toBe("json");
+    expect(
+      JSON.parse(
+        buildClaudeMetadataUserId({
+          keyId,
+          sessionId,
+        })
+      )
+    ).toEqual({
+      device_id: buildClaudeMetadataDeviceId(keyId),
+      account_uuid: "",
+      session_id: sessionId,
+    });
+  });
+
+  test("注入时应保留已有 metadata.user_id", () => {
+    const message = {
+      metadata: {
+        user_id: "existing_user_id",
+        source: "client",
+      },
+    };
+
+    expect(
+      injectClaudeMetadataUserIdWithContext(message, {
+        keyId: 1,
+        sessionId: "sess_should_not_override",
+        userAgent: "claude-cli/2.1.78 (external, cli)",
+      })
+    ).toBe(message);
+  });
+});

+ 150 - 0
src/lib/claude-code/metadata-user-id.ts

@@ -0,0 +1,150 @@
+import crypto from "node:crypto";
+import { parseUserAgent } from "@/lib/ua-parser";
+import { isVersionLess } from "@/lib/version";
+
+export const CLAUDE_CODE_METADATA_USER_ID_JSON_SWITCH_VERSION = "2.1.78";
+
+export type ClaudeMetadataUserIdFormat = "legacy" | "json";
+
+export type ClaudeMetadataUserIdParseResult = {
+  sessionId: string | null;
+  format: ClaudeMetadataUserIdFormat | null;
+  deviceId: string | null;
+  accountUuid: string | null;
+};
+
+type BuildClaudeMetadataUserIdArgs = {
+  keyId: number;
+  sessionId: string;
+  userAgent?: string | null;
+};
+
+type InjectClaudeMetadataUserIdArgs = {
+  keyId: number | null | undefined;
+  sessionId: string | null | undefined;
+  userAgent?: string | null;
+};
+
+const LEGACY_PATTERN = /^user_(.+?)_account__session_(.+)$/;
+const CLAUDE_CODE_CLIENT_TYPES = new Set(["claude-cli", "claude-vscode", "claude-cli-unknown"]);
+
+function emptyParseResult(): ClaudeMetadataUserIdParseResult {
+  return {
+    sessionId: null,
+    format: null,
+    deviceId: null,
+    accountUuid: null,
+  };
+}
+
+export function buildClaudeMetadataDeviceId(keyId: number): string {
+  return crypto.createHash("sha256").update(`claude_user_${keyId}`).digest("hex");
+}
+
+export function resolveClaudeMetadataUserIdFormat(
+  userAgent?: string | null
+): ClaudeMetadataUserIdFormat {
+  const clientInfo = parseUserAgent(userAgent);
+  if (!clientInfo || !CLAUDE_CODE_CLIENT_TYPES.has(clientInfo.clientType)) {
+    return "json";
+  }
+
+  return isVersionLess(clientInfo.version, CLAUDE_CODE_METADATA_USER_ID_JSON_SWITCH_VERSION)
+    ? "legacy"
+    : "json";
+}
+
+export function parseClaudeMetadataUserId(userId: unknown): ClaudeMetadataUserIdParseResult {
+  if (typeof userId !== "string") {
+    return emptyParseResult();
+  }
+
+  const trimmed = userId.trim();
+  if (!trimmed) {
+    return emptyParseResult();
+  }
+
+  try {
+    const parsed = JSON.parse(trimmed);
+    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;
+
+      if (sessionId) {
+        return {
+          sessionId,
+          format: "json",
+          deviceId: typeof parsedObj.device_id === "string" ? parsedObj.device_id : null,
+          accountUuid: typeof parsedObj.account_uuid === "string" ? parsedObj.account_uuid : null,
+        };
+      }
+    }
+  } catch {
+    // Ignore JSON parse failure and fall back to legacy format.
+  }
+
+  const legacyMatch = trimmed.match(LEGACY_PATTERN);
+  if (!legacyMatch) {
+    return emptyParseResult();
+  }
+
+  const [, deviceId, sessionId] = legacyMatch;
+  if (!sessionId) {
+    return emptyParseResult();
+  }
+
+  return {
+    sessionId,
+    format: "legacy",
+    deviceId: deviceId || null,
+    accountUuid: null,
+  };
+}
+
+export function buildClaudeMetadataUserId(args: BuildClaudeMetadataUserIdArgs): string {
+  const deviceId = buildClaudeMetadataDeviceId(args.keyId);
+  const format = resolveClaudeMetadataUserIdFormat(args.userAgent);
+
+  if (format === "legacy") {
+    return `user_${deviceId}_account__session_${args.sessionId}`;
+  }
+
+  return JSON.stringify({
+    device_id: deviceId,
+    account_uuid: "",
+    session_id: args.sessionId,
+  });
+}
+
+export function injectClaudeMetadataUserIdWithContext(
+  message: Record<string, unknown>,
+  args: InjectClaudeMetadataUserIdArgs
+): Record<string, unknown> {
+  const existingMetadata =
+    typeof message.metadata === "object" && message.metadata !== null
+      ? (message.metadata as Record<string, unknown>)
+      : undefined;
+
+  if (existingMetadata?.user_id !== undefined && existingMetadata?.user_id !== null) {
+    return message;
+  }
+
+  if (args.keyId == null || !args.sessionId) {
+    return message;
+  }
+
+  return {
+    ...message,
+    metadata: {
+      ...existingMetadata,
+      user_id: buildClaudeMetadataUserId({
+        keyId: args.keyId,
+        sessionId: args.sessionId,
+        userAgent: args.userAgent,
+      }),
+    },
+  };
+}

+ 9 - 16
src/lib/session-manager.ts

@@ -3,6 +3,7 @@ import "server-only";
 import crypto from "node:crypto";
 import { extractCodexSessionId } from "@/app/v1/_lib/codex/session-extractor";
 import { sanitizeHeaders, sanitizeUrl } from "@/app/v1/_lib/proxy/errors";
+import { parseClaudeMetadataUserId } from "@/lib/claude-code/metadata-user-id";
 import { getEnvConfig } from "@/lib/config/env.schema";
 import { logger } from "@/lib/logger";
 import {
@@ -119,7 +120,7 @@ export class SessionManager {
    * 从客户端请求中提取 session_id(支持 metadata 或 header)
    *
    * 优先级:
-   * 1. metadata.user_id (Claude Code 主要方式,典型格式: "user_{hash}_account__session_{sessionId}")
+   * 1. metadata.user_id (Claude Code 主要方式,兼容旧字符串格式和新 JSON 字符串格式)
    * 2. metadata.session_id (备选方式)
    */
   static extractClientSessionId(
@@ -149,21 +150,13 @@ export class SessionManager {
     const metadataObj = metadata as Record<string, unknown>;
 
     // 方案 A: 从 metadata.user_id 中提取 (Claude Code 主要方式)
-    // 典型格式: "user_{hash}_account__session_{sessionId}"
-    if (typeof metadataObj.user_id === "string" && metadataObj.user_id.length > 0) {
-      const userId = metadataObj.user_id;
-      const sessionMarker = "_session_";
-      const markerIndex = userId.indexOf(sessionMarker);
-
-      if (markerIndex !== -1) {
-        const extractedSessionId = userId.substring(markerIndex + sessionMarker.length);
-        if (extractedSessionId.length > 0) {
-          logger.trace("SessionManager: Extracted session from metadata.user_id", {
-            sessionId: extractedSessionId,
-          });
-          return extractedSessionId;
-        }
-      }
+    const extractedFromUserId = parseClaudeMetadataUserId(metadataObj.user_id);
+    if (extractedFromUserId.sessionId) {
+      logger.trace("SessionManager: Extracted session from metadata.user_id", {
+        sessionId: extractedFromUserId.sessionId,
+        format: extractedFromUserId.format,
+      });
+      return extractedFromUserId.sessionId;
     }
 
     // 方案 B: 直接从 metadata.session_id 读取 (备选方案)

+ 46 - 0
tests/unit/lib/session-manager-helpers.test.ts

@@ -29,6 +29,7 @@ async function loadHelpers() {
   return {
     headersToSanitizedObject: mod.headersToSanitizedObject,
     parseHeaderRecord: mod.parseHeaderRecord,
+    extractClientSessionId: mod.SessionManager.extractClientSessionId,
   };
 }
 
@@ -125,4 +126,49 @@ describe("SessionManager 辅助函数", () => {
     expect(headersToSanitizedObject(headers)).toEqual({ "x-test": "a:b:c" });
     expect(sanitizeHeadersMock).toHaveBeenCalledWith(headers);
   });
+
+  test("extractClientSessionId:应兼容旧格式 metadata.user_id", async () => {
+    vi.clearAllMocks();
+    const { extractClientSessionId } = await loadHelpers();
+
+    expect(
+      extractClientSessionId({
+        metadata: {
+          user_id:
+            "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_sess_legacy_123",
+        },
+      })
+    ).toBe("sess_legacy_123");
+  });
+
+  test("extractClientSessionId:应兼容 JSON 字符串 metadata.user_id", async () => {
+    vi.clearAllMocks();
+    const { extractClientSessionId } = await loadHelpers();
+
+    expect(
+      extractClientSessionId({
+        metadata: {
+          user_id: JSON.stringify({
+            device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+            account_uuid: "",
+            session_id: "sess_json_123",
+          }),
+        },
+      })
+    ).toBe("sess_json_123");
+  });
+
+  test("extractClientSessionId:无效 user_id 时应回退到 metadata.session_id", async () => {
+    vi.clearAllMocks();
+    const { extractClientSessionId } = await loadHelpers();
+
+    expect(
+      extractClientSessionId({
+        metadata: {
+          user_id: "invalid_user_id",
+          session_id: "sess_fallback_123",
+        },
+      })
+    ).toBe("sess_fallback_123");
+  });
 });

+ 33 - 7
tests/unit/proxy/client-detector.test.ts

@@ -10,6 +10,14 @@ import {
 } from "@/app/v1/_lib/proxy/client-detector";
 import type { ProxySession } from "@/app/v1/_lib/proxy/session";
 
+const LEGACY_METADATA_USER_ID =
+  "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_sess_legacy_123";
+const JSON_METADATA_USER_ID = JSON.stringify({
+  device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+  account_uuid: "",
+  session_id: "sess_json_123",
+});
+
 type SessionOptions = {
   userAgent?: string | null;
   xApp?: string | null;
@@ -49,7 +57,7 @@ function createConfirmedClaudeCodeSession(userAgent: string): ProxySession {
     userAgent,
     xApp: "cli",
     anthropicBeta: "claude-code-test",
-    metadataUserId: "user_123",
+    metadataUserId: LEGACY_METADATA_USER_ID,
   });
 }
 
@@ -92,7 +100,7 @@ describe("client-detector", () => {
         userAgent: "claude-cli/1.0.0 (external, cli)",
         xApp: "cli",
         anthropicBeta: "interleaved-thinking-2025-05-14",
-        metadataUserId: "user_abc",
+        metadataUserId: LEGACY_METADATA_USER_ID,
       });
 
       const result = detectClientFull(session, "claude-code");
@@ -111,7 +119,25 @@ describe("client-detector", () => {
         userAgent: "claude-cli/1.0.0 (external, cli)",
         xApp: "cli",
         anthropicBeta: "claude-code-cache-control-20260101",
-        metadataUserId: "user_abc",
+        metadataUserId: LEGACY_METADATA_USER_ID,
+      });
+
+      const result = detectClientFull(session, "claude-code");
+      expect(result.hubConfirmed).toBe(true);
+      expect(result.signals).toEqual([
+        "x-app-cli",
+        "ua-prefix",
+        "betas-present",
+        "metadata-user-id",
+      ]);
+    });
+
+    test("should confirm when metadata.user_id uses JSON string format", () => {
+      const session = createMockSession({
+        userAgent: "claude-cli/2.1.78 (external, cli)",
+        xApp: "cli",
+        anthropicBeta: "interleaved-thinking-2025-05-14",
+        metadataUserId: JSON_METADATA_USER_ID,
       });
 
       const result = detectClientFull(session, "claude-code");
@@ -130,7 +156,7 @@ describe("client-detector", () => {
         options: {
           userAgent: "claude-cli/1.0.0 (external, cli)",
           anthropicBeta: "some-beta",
-          metadataUserId: "user_abc",
+          metadataUserId: LEGACY_METADATA_USER_ID,
         },
       },
       {
@@ -139,7 +165,7 @@ describe("client-detector", () => {
           userAgent: "GeminiCLI/1.0",
           xApp: "cli",
           anthropicBeta: "some-beta",
-          metadataUserId: "user_abc",
+          metadataUserId: LEGACY_METADATA_USER_ID,
         },
       },
       {
@@ -147,7 +173,7 @@ describe("client-detector", () => {
         options: {
           userAgent: "claude-cli/1.0.0 (external, cli)",
           xApp: "cli",
-          metadataUserId: "user_abc",
+          metadataUserId: LEGACY_METADATA_USER_ID,
         },
       },
       {
@@ -228,7 +254,7 @@ describe("client-detector", () => {
         userAgent: "claude-cli/1.2.3 external, cli",
         xApp: "cli",
         anthropicBeta: "claude-code-a",
-        metadataUserId: "user_abc",
+        metadataUserId: LEGACY_METADATA_USER_ID,
       });
       const result = detectClientFull(session, "claude-code");
 

+ 24 - 3
tests/unit/proxy/client-guard.test.ts

@@ -2,6 +2,14 @@ import { describe, expect, test, vi } from "vitest";
 import { ProxyClientGuard } from "@/app/v1/_lib/proxy/client-guard";
 import type { ProxySession } from "@/app/v1/_lib/proxy/session";
 
+const LEGACY_METADATA_USER_ID =
+  "user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_sess_legacy_123";
+const JSON_METADATA_USER_ID = JSON.stringify({
+  device_id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+  account_uuid: "",
+  session_id: "sess_json_123",
+});
+
 // Mock ProxyResponses
 vi.mock("@/app/v1/_lib/proxy/responses", () => ({
   ProxyResponses: {
@@ -34,7 +42,8 @@ function createClaudeCodeSession(
   userAgent: string,
   allowedClients: string[] = [],
   blockedClients: string[] = [],
-  betaHeader = "claude-code-test"
+  betaHeader = "claude-code-test",
+  metadataUserId = LEGACY_METADATA_USER_ID
 ): ProxySession {
   const headers = new Headers();
   headers.set("x-app", "cli");
@@ -42,7 +51,7 @@ function createClaudeCodeSession(
   return {
     userAgent,
     headers,
-    request: { message: { metadata: { user_id: "user_123" } } },
+    request: { message: { metadata: { user_id: metadataUserId } } },
     authState: {
       user: { allowedClients, blockedClients },
     },
@@ -302,7 +311,7 @@ describe("ProxyClientGuard", () => {
       const session = {
         userAgent: "claude-cli/2.0.70 (external, cli)",
         headers,
-        request: { message: { metadata: { user_id: "user_abc" } } },
+        request: { message: { metadata: { user_id: LEGACY_METADATA_USER_ID } } },
         authState: { user: { allowedClients: ["claude-code"], blockedClients: [] } },
       } as unknown as import("@/app/v1/_lib/proxy/session").ProxySession;
       const result = await ProxyClientGuard.ensure(session);
@@ -317,5 +326,17 @@ describe("ProxyClientGuard", () => {
       const result = await ProxyClientGuard.ensure(session);
       expect(result).toBeNull();
     });
+
+    test("should allow JSON string metadata.user_id with claude-code allowlist", async () => {
+      const session = createClaudeCodeSession(
+        "claude-cli/2.1.78 (external, cli)",
+        ["claude-code"],
+        [],
+        "interleaved-thinking-2025-05-14",
+        JSON_METADATA_USER_ID
+      );
+      const result = await ProxyClientGuard.ensure(session);
+      expect(result).toBeNull();
+    });
   });
 });

+ 54 - 12
tests/unit/proxy/metadata-injection.test.ts

@@ -2,18 +2,23 @@ import { describe, expect, it } from "vitest";
 import { injectClaudeMetadataUserId } from "@/app/v1/_lib/proxy/forwarder";
 import { ProxySession } from "@/app/v1/_lib/proxy/session";
 
+const LEGACY_USER_AGENT = "claude-cli/2.1.77 (external, cli)";
+const JSON_USER_AGENT = "claude-cli/2.1.78 (external, cli)";
+
 function createSession(
   keyId: number | null | undefined = 123,
-  sessionId: string | null | undefined = "sess_test"
+  sessionId: string | null | undefined = "sess_test",
+  userAgent: string | null = LEGACY_USER_AGENT
 ): ProxySession {
   const session = Object.create(ProxySession.prototype) as ProxySession;
   (session as Record<string, unknown>).authState =
     keyId === undefined ? undefined : { key: { id: keyId } };
   (session as Record<string, unknown>).sessionId = sessionId ?? null;
+  (session as Record<string, unknown>).userAgent = userAgent;
   return session;
 }
 
-function extractUserHash(userId: string): string {
+function extractLegacyDeviceId(userId: string): string {
   const match = userId.match(/^user_([a-f0-9]{64})_account__session_/);
   if (!match) {
     throw new Error(`Unexpected user_id format: ${userId}`);
@@ -21,10 +26,14 @@ function extractUserHash(userId: string): string {
   return match[1];
 }
 
+function parseJsonUserId(userId: string): Record<string, unknown> {
+  return JSON.parse(userId) as Record<string, unknown>;
+}
+
 describe("injectClaudeMetadataUserId", () => {
-  it("无 metadata 时应正确注入 user_id", () => {
+  it("低版本 Claude Code 无 metadata 时应注入旧格式 user_id", () => {
     const message: Record<string, unknown> = { model: "claude-3-5-sonnet" };
-    const session = createSession(42, "sess_abc123");
+    const session = createSession(42, "sess_abc123", LEGACY_USER_AGENT);
 
     const result = injectClaudeMetadataUserId(message, session);
     const metadata = result.metadata as Record<string, unknown>;
@@ -33,6 +42,21 @@ describe("injectClaudeMetadataUserId", () => {
     expect(metadata.user_id).toMatch(/^user_[a-f0-9]{64}_account__session_sess_abc123$/);
   });
 
+  it("新版本 Claude Code 无 metadata 时应注入 JSON 字符串 user_id", () => {
+    const message: Record<string, unknown> = { model: "claude-3-5-sonnet" };
+    const session = createSession(42, "sess_json_123", JSON_USER_AGENT);
+
+    const result = injectClaudeMetadataUserId(message, session);
+    const metadata = result.metadata as Record<string, unknown>;
+
+    expect(result).not.toBe(message);
+    expect(parseJsonUserId(metadata.user_id as string)).toEqual({
+      device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
+      account_uuid: "",
+      session_id: "sess_json_123",
+    });
+  });
+
   it("已有 metadata.user_id 时应保持原样不覆盖", () => {
     const message: Record<string, unknown> = {
       metadata: {
@@ -85,8 +109,8 @@ describe("injectClaudeMetadataUserId", () => {
   it("相同 keyId 应生成相同 hash", () => {
     const messageA: Record<string, unknown> = {};
     const messageB: Record<string, unknown> = {};
-    const sessionA = createSession(7, "sess_one");
-    const sessionB = createSession(7, "sess_two");
+    const sessionA = createSession(7, "sess_one", LEGACY_USER_AGENT);
+    const sessionB = createSession(7, "sess_two", LEGACY_USER_AGENT);
 
     const userIdA = (
       injectClaudeMetadataUserId(messageA, sessionA).metadata as Record<string, unknown>
@@ -95,14 +119,14 @@ describe("injectClaudeMetadataUserId", () => {
       injectClaudeMetadataUserId(messageB, sessionB).metadata as Record<string, unknown>
     ).user_id as string;
 
-    expect(extractUserHash(userIdA)).toBe(extractUserHash(userIdB));
+    expect(extractLegacyDeviceId(userIdA)).toBe(extractLegacyDeviceId(userIdB));
   });
 
   it("不同 keyId 应生成不同 hash", () => {
     const messageA: Record<string, unknown> = {};
     const messageB: Record<string, unknown> = {};
-    const sessionA = createSession(7, "sess_same");
-    const sessionB = createSession(8, "sess_same");
+    const sessionA = createSession(7, "sess_same", LEGACY_USER_AGENT);
+    const sessionB = createSession(8, "sess_same", LEGACY_USER_AGENT);
 
     const userIdA = (
       injectClaudeMetadataUserId(messageA, sessionA).metadata as Record<string, unknown>
@@ -111,20 +135,38 @@ describe("injectClaudeMetadataUserId", () => {
       injectClaudeMetadataUserId(messageB, sessionB).metadata as Record<string, unknown>
     ).user_id as string;
 
-    expect(extractUserHash(userIdA)).not.toBe(extractUserHash(userIdB));
+    expect(extractLegacyDeviceId(userIdA)).not.toBe(extractLegacyDeviceId(userIdB));
+  });
+
+  it("无法获取版本时应默认注入 JSON 字符串 user_id", () => {
+    const message: Record<string, unknown> = {};
+    const session = createSession(42, "sess_unknown", null);
+
+    const result = injectClaudeMetadataUserId(message, session);
+    const metadata = result.metadata as Record<string, unknown>;
+
+    expect(parseJsonUserId(metadata.user_id as string)).toEqual({
+      device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
+      account_uuid: "",
+      session_id: "sess_unknown",
+    });
   });
 
   it("metadata 为非对象类型时应安全处理", () => {
     const message: Record<string, unknown> = {
       metadata: "not-an-object",
     };
-    const session = createSession(42, "sess_abc123");
+    const session = createSession(42, "sess_abc123", JSON_USER_AGENT);
 
     const result = injectClaudeMetadataUserId(message, session);
     const metadata = result.metadata as Record<string, unknown>;
 
     expect(result).not.toBe(message);
     expect(typeof metadata).toBe("object");
-    expect(metadata.user_id).toMatch(/^user_[a-f0-9]{64}_account__session_sess_abc123$/);
+    expect(parseJsonUserId(metadata.user_id as string)).toEqual({
+      device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
+      account_uuid: "",
+      session_id: "sess_abc123",
+    });
   });
 });

+ 83 - 0
tests/unit/proxy/session-guard-warmup-intercept.test.ts

@@ -70,6 +70,7 @@ function createMockSession(overrides: Partial<ProxySession> = {}): ProxySession
     requestUrl: "http://localhost/v1/messages",
     method: "POST",
     originalFormat: "claude",
+    addSpecialSetting: vi.fn(),
 
     sessionId: null,
     setSessionId(id: string) {
@@ -124,4 +125,86 @@ describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
     expect(trackSessionMock).toHaveBeenCalledTimes(1);
     expect(trackSessionMock).toHaveBeenCalledWith("session_assigned", 1, 1);
   });
+
+  test("Claude 旧版本请求缺少 user_id 但有 metadata.session_id 时,应先补全旧格式再提取", async () => {
+    const ProxySessionGuard = await loadGuard();
+    extractClientSessionIdMock.mockImplementation((requestMessage: Record<string, unknown>) => {
+      const metadata =
+        requestMessage.metadata && typeof requestMessage.metadata === "object"
+          ? (requestMessage.metadata as Record<string, unknown>)
+          : {};
+
+      if (typeof metadata.session_id === "string") {
+        return metadata.session_id;
+      }
+
+      if (typeof metadata.user_id === "string") {
+        const marker = "_account__session_";
+        const markerIndex = metadata.user_id.indexOf(marker);
+        return markerIndex === -1 ? null : metadata.user_id.slice(markerIndex + marker.length);
+      }
+
+      return null;
+    });
+
+    const session = createMockSession({
+      userAgent: "claude-cli/2.1.77 (external, cli)",
+      request: {
+        message: {
+          metadata: {
+            session_id: "sess_legacy_seed",
+          },
+        },
+        model: "claude-sonnet-4-5-20250929",
+      },
+    });
+
+    await ProxySessionGuard.ensure(session);
+
+    expect((session.request.message.metadata as Record<string, unknown>).user_id).toMatch(
+      /^user_[a-f0-9]{64}_account__session_sess_legacy_seed$/
+    );
+    expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], "sess_legacy_seed");
+  });
+
+  test("Claude 无法获取版本且缺少 session 标识时,应生成 JSON user_id 供后续链路复用", 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"
+          ? (requestMessage.metadata as Record<string, unknown>)
+          : {};
+
+      if (typeof metadata.user_id === "string") {
+        try {
+          const parsed = JSON.parse(metadata.user_id) as { session_id?: string };
+          return parsed.session_id ?? null;
+        } catch {
+          return null;
+        }
+      }
+
+      return null;
+    });
+
+    const session = createMockSession({
+      userAgent: null,
+      request: {
+        message: {},
+        model: "claude-sonnet-4-5-20250929",
+      },
+    });
+
+    await ProxySessionGuard.ensure(session);
+
+    expect(
+      JSON.parse((session.request.message.metadata as Record<string, unknown>).user_id as string)
+    ).toEqual({
+      device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
+      account_uuid: "",
+      session_id: "sess_generated_by_guard",
+    });
+    expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], "sess_generated_by_guard");
+  });
 });