Skip to content

Commit dc4c62a

Browse files
committed
feat: enhance user ID parsing with JSON support and legacy fallback
1 parent 97b11b2 commit dc4c62a

4 files changed

Lines changed: 144 additions & 33 deletions

File tree

src/lib/utils.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,50 @@ interface PayloadMessage {
115115
type?: string
116116
}
117117

118+
const isRecord = (value: unknown): value is Record<string, unknown> =>
119+
typeof value === "object" && value !== null
120+
121+
const getUserIdJsonField = (
122+
userIdPayload: Record<string, unknown> | null,
123+
field: string,
124+
): string | null => {
125+
const value = userIdPayload?.[field]
126+
return typeof value === "string" && value.length > 0 ? value : null
127+
}
128+
129+
const parseJsonUserId = (userId: string): Record<string, unknown> | null => {
130+
try {
131+
const parsed: unknown = JSON.parse(userId)
132+
return isRecord(parsed) ? parsed : null
133+
} catch {
134+
return null
135+
}
136+
}
137+
138+
export const parseUserIdMetadata = (
139+
userId: string | undefined,
140+
): { safetyIdentifier: string | null; sessionId: string | null } => {
141+
if (!userId || typeof userId !== "string") {
142+
return { safetyIdentifier: null, sessionId: null }
143+
}
144+
145+
const legacySafetyIdentifier =
146+
userId.match(/user_([^_]+)_account/)?.[1] ?? null
147+
const legacySessionId = userId.match(/_session_(.+)$/)?.[1] ?? null
148+
149+
const parsedUserId =
150+
legacySafetyIdentifier && legacySessionId ? null : parseJsonUserId(userId)
151+
152+
const safetyIdentifier =
153+
legacySafetyIdentifier
154+
?? getUserIdJsonField(parsedUserId, "device_id")
155+
?? getUserIdJsonField(parsedUserId, "account_uuid")
156+
const sessionId =
157+
legacySessionId ?? getUserIdJsonField(parsedUserId, "session_id")
158+
159+
return { safetyIdentifier, sessionId }
160+
}
161+
118162
const findLastUserContent = (
119163
messages: Array<PayloadMessage>,
120164
): string | null => {
@@ -161,19 +205,13 @@ export const getRootSessionId = (
161205
anthropicPayload: AnthropicMessagesPayload,
162206
c: Context,
163207
): string | undefined => {
164-
let sessionId: string | undefined
165-
if (anthropicPayload.metadata?.user_id) {
166-
const sessionMatch = new RegExp(/_session_(.+)$/).exec(
167-
anthropicPayload.metadata.user_id,
168-
)
169-
sessionId = sessionMatch ? sessionMatch[1] : undefined
170-
} else {
171-
sessionId = c.req.header("x-session-id")
172-
}
173-
if (sessionId) {
174-
return getUUID(sessionId)
175-
}
176-
return sessionId
208+
const userId = anthropicPayload.metadata?.user_id
209+
const sessionId =
210+
userId ?
211+
parseUserIdMetadata(userId).sessionId || undefined
212+
: c.req.header("x-session-id")
213+
214+
return sessionId ? getUUID(sessionId) : sessionId
177215
}
178216

179217
export const getUUID = (content: string): string => {

src/routes/messages/responses-translation.ts

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getExtraPromptForModel,
55
getReasoningEffortForModel,
66
} from "~/lib/config"
7+
import { parseUserIdMetadata } from "~/lib/utils"
78
import {
89
type ResponsesPayload,
910
type ResponseInputCompaction,
@@ -64,7 +65,7 @@ export const translateAnthropicMessagesToResponsesPayload = (
6465
const translatedTools = convertAnthropicTools(payload.tools)
6566
const toolChoice = convertAnthropicToolChoice(payload.tool_choice)
6667

67-
const { safetyIdentifier, promptCacheKey } = parseUserId(
68+
const { safetyIdentifier, sessionId: promptCacheKey } = parseUserIdMetadata(
6869
payload.metadata?.user_id,
6970
)
7071

@@ -751,24 +752,6 @@ const isResponseOutputRefusal = (
751752
&& "type" in block
752753
&& (block as { type?: unknown }).type === "refusal"
753754

754-
const parseUserId = (
755-
userId: string | undefined,
756-
): { safetyIdentifier: string | null; promptCacheKey: string | null } => {
757-
if (!userId || typeof userId !== "string") {
758-
return { safetyIdentifier: null, promptCacheKey: null }
759-
}
760-
761-
// Parse safety_identifier: content between "user_" and "_account"
762-
const userMatch = userId.match(/user_([^_]+)_account/)
763-
const safetyIdentifier = userMatch ? userMatch[1] : null
764-
765-
// Parse prompt_cache_key: content after "_session_"
766-
const sessionMatch = userId.match(/_session_(.+)$/)
767-
const promptCacheKey = sessionMatch ? sessionMatch[1] : null
768-
769-
return { safetyIdentifier, promptCacheKey }
770-
}
771-
772755
const convertToolResultContent = (
773756
content: string | Array<AnthropicTextBlock | AnthropicImageBlock>,
774757
): string | Array<ResponseInputContent> => {

tests/responses-translation.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ const samplePayload = {
4646
],
4747
} as unknown as AnthropicMessagesPayload
4848

49+
const jsonStyleUserId = JSON.stringify({
50+
device_id: "3f4a1b7c8d9e0f1234567890abcdef1234567890abcdef1234567890abcdef12",
51+
account_uuid: "",
52+
session_id: "2c4e1cf0-7a67-4d2e-9a4b-1d16d3f44752",
53+
})
54+
55+
const legacyStyleUserId =
56+
"user_8b7e2c1d4f6a9b3c0d1e2f3456789abcdeffedcba9876543210fedcba1234567_account__session_7d0e2f61-4b5c-4a9d-8f11-2c3d4e5f6a7b"
57+
4958
describe("translateAnthropicMessagesToResponsesPayload", () => {
5059
it("converts anthropic text blocks into response input messages", () => {
5160
const result = translateAnthropicMessagesToResponsesPayload(samplePayload)
@@ -67,6 +76,34 @@ describe("translateAnthropicMessagesToResponsesPayload", () => {
6776
"hi",
6877
])
6978
})
79+
80+
it("extracts identifiers from JSON-like user_id metadata", () => {
81+
const result = translateAnthropicMessagesToResponsesPayload({
82+
...samplePayload,
83+
metadata: {
84+
user_id: jsonStyleUserId,
85+
},
86+
})
87+
88+
expect(result.safety_identifier).toBe(
89+
"3f4a1b7c8d9e0f1234567890abcdef1234567890abcdef1234567890abcdef12",
90+
)
91+
expect(result.prompt_cache_key).toBe("2c4e1cf0-7a67-4d2e-9a4b-1d16d3f44752")
92+
})
93+
94+
it("keeps legacy user_id parsing before JSON fallback", () => {
95+
const result = translateAnthropicMessagesToResponsesPayload({
96+
...samplePayload,
97+
metadata: {
98+
user_id: legacyStyleUserId,
99+
},
100+
})
101+
102+
expect(result.safety_identifier).toBe(
103+
"8b7e2c1d4f6a9b3c0d1e2f3456789abcdeffedcba9876543210fedcba1234567",
104+
)
105+
expect(result.prompt_cache_key).toBe("7d0e2f61-4b5c-4a9d-8f11-2c3d4e5f6a7b")
106+
})
70107
})
71108

72109
describe("translateResponsesResultToAnthropic", () => {

tests/utils.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
import type { Context } from "hono"
2+
13
import { expect, test } from "bun:test"
24
import { createHash, randomUUID } from "node:crypto"
35

4-
import { getUUID } from "../src/lib/utils"
6+
import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types"
7+
8+
import { getRootSessionId, getUUID } from "../src/lib/utils"
9+
10+
const jsonStyleUserId = JSON.stringify({
11+
device_id: "3f4a1b7c8d9e0f1234567890abcdef1234567890abcdef1234567890abcdef12",
12+
account_uuid: "",
13+
session_id: "2c4e1cf0-7a67-4d2e-9a4b-1d16d3f44752",
14+
})
15+
16+
const legacyStyleUserId =
17+
"user_8b7e2c1d4f6a9b3c0d1e2f3456789abcdeffedcba9876543210fedcba1234567_account__session_7d0e2f61-4b5c-4a9d-8f11-2c3d4e5f6a7b"
518

619
const getLegacyUUID = (content: string): string => {
720
const hash32 = createHash("sha256").update(content).digest("hex").slice(0, 32)
@@ -43,3 +56,43 @@ test("prints randomUUID and deterministic UUID for comparison", () => {
4356
expect(legacy).not.toBe(derived)
4457
expect(random).not.toBe(derived)
4558
})
59+
60+
test("getRootSessionId supports JSON-like user_id metadata", () => {
61+
const anthropicPayload = {
62+
model: "claude-3-5-sonnet",
63+
messages: [],
64+
max_tokens: 0,
65+
metadata: {
66+
user_id: jsonStyleUserId,
67+
},
68+
} as AnthropicMessagesPayload
69+
const context = {
70+
req: {
71+
header: (_name: string) => undefined,
72+
},
73+
} as unknown as Context
74+
75+
expect(getRootSessionId(anthropicPayload, context)).toBe(
76+
getUUID("2c4e1cf0-7a67-4d2e-9a4b-1d16d3f44752"),
77+
)
78+
})
79+
80+
test("getRootSessionId keeps legacy parsing before JSON fallback", () => {
81+
const anthropicPayload = {
82+
model: "claude-3-5-sonnet",
83+
messages: [],
84+
max_tokens: 0,
85+
metadata: {
86+
user_id: legacyStyleUserId,
87+
},
88+
} as AnthropicMessagesPayload
89+
const context = {
90+
req: {
91+
header: (_name: string) => undefined,
92+
},
93+
} as unknown as Context
94+
95+
expect(getRootSessionId(anthropicPayload, context)).toBe(
96+
getUUID("7d0e2f61-4b5c-4a9d-8f11-2c3d4e5f6a7b"),
97+
)
98+
})

0 commit comments

Comments
 (0)