From ea771ad5bc3cb34dab0fa6e097e79a66d18a1271 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:46:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E9=80=8F=E4=BC=A0=20conversationI?= =?UTF-8?q?d=20=E8=87=B3=20Anthropic=20metadata.user=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为下游网关的会话级粘性路由 (session affinity) 提供稳定会话键: conversationId 存在时注入 metadata.user_id, 缺省时不注入。 - 与 OpenAI 路径已有的 prompt_cache_key 透传语义对称 - 官方客户端 Claude Code 同样在该字段携带 session 维度 - 新增 3 用例单测 (注入/缺省/会话隔离) Co-authored-by: Claude Code --- Cursor++/src/server/handlers/llm/anthropic.ts | 7 ++ .../server/tests/anthropicAffinity.test.ts | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 Cursor++/src/server/tests/anthropicAffinity.test.ts diff --git a/Cursor++/src/server/handlers/llm/anthropic.ts b/Cursor++/src/server/handlers/llm/anthropic.ts index d271c0a..13253cc 100644 --- a/Cursor++/src/server/handlers/llm/anthropic.ts +++ b/Cursor++/src/server/handlers/llm/anthropic.ts @@ -89,6 +89,13 @@ export class AnthropicProvider implements LLMProvider { stream: true, }; + // 会话亲和: 透传 conversationId 至 metadata.user_id, 供下游网关做 + // 会话级粘性路由 (session affinity)。官方客户端 Claude Code 同样在该 + // 字段中携带 session 维度; 缺省时不注入, 保持请求体不变。 + if (request.conversationId) { + params.metadata = { user_id: request.conversationId }; + } + if (cached.system) { params.system = cached.system; } diff --git a/Cursor++/src/server/tests/anthropicAffinity.test.ts b/Cursor++/src/server/tests/anthropicAffinity.test.ts new file mode 100644 index 0000000..b1c7a71 --- /dev/null +++ b/Cursor++/src/server/tests/anthropicAffinity.test.ts @@ -0,0 +1,75 @@ +import type Anthropic from '@anthropic-ai/sdk' +import type { ProviderEntry } from '../data/defaults' +import { describe, expect, it } from 'vitest' +import { AnthropicProvider } from '../handlers/llm/anthropic' + +// 会话亲和契约: conversationId 必须进入请求体 metadata.user_id, +// 供下游网关做会话级粘性路由; 未提供时不得注入空 metadata。 +// 通过拦截 SDK client 的 messages.stream 参数直接断言 (不发起真实请求)。 + +type CapturedParams = Anthropic.MessageCreateParamsStreaming + +function makeProvider(): { provider: AnthropicProvider, captured: CapturedParams[] } { + const captured: CapturedParams[] = [] + const fakeStream = (params: CapturedParams) => { + captured.push(params) + throw new Error('STOP_TEST') + } + const client = { + messages: { stream: fakeStream }, + beta: { messages: { stream: fakeStream } }, + } as unknown as Anthropic + const entry = { + id: 'test', + name: 'Test', + type: 'anthropic', + baseUrl: 'https://api.example.com', + auth: { kind: 'apiKey', value: 'sk-test' }, + models: [], + } as unknown as ProviderEntry + const provider = new AnthropicProvider(entry) + // @ts-expect-error 访问私有字段替换 client + provider.client = client + return { provider, captured } +} + +async function consumeStream(provider: AnthropicProvider, conversationId?: string): Promise { + const stream = provider.stream({ + model: 'test-model', + maxTokens: 1024, + messages: [{ role: 'user', content: 'hi' }], + ...(conversationId ? { conversationId } : {}), + }) + try { + for await (const _ of stream) { + // 迭代到 fakeStream 抛错为止 + } + } + catch (err) { + if (!(err instanceof Error && err.message === 'STOP_TEST')) + throw err + } +} + +describe('anthropic conversation affinity (metadata.user_id)', () => { + it('passes conversationId as metadata.user_id when present', async () => { + const { provider, captured } = makeProvider() + await consumeStream(provider, 'conv-affinity-42') + expect(captured.length).toBe(1) + expect(captured[0].metadata).toEqual({ user_id: 'conv-affinity-42' }) + }) + + it('omits metadata entirely when conversationId is absent', async () => { + const { provider, captured } = makeProvider() + await consumeStream(provider) + expect(captured.length).toBe(1) + expect(captured[0].metadata).toBeUndefined() + }) + + it('keeps a distinct user_id per conversation (no cross-session leakage)', async () => { + const { provider, captured } = makeProvider() + await consumeStream(provider, 'conv-A') + await consumeStream(provider, 'conv-B') + expect(captured.map(p => p.metadata?.user_id)).toEqual(['conv-A', 'conv-B']) + }) +})