feat(llm): send DeepSeek user identity header

This commit is contained in:
kingwl
2026-08-11 12:39:04 +08:00
parent ac266ed2de
commit 93b0451ed5
36 changed files with 216 additions and 62 deletions

View File

@@ -1,7 +1,7 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -19,6 +19,12 @@ import { assemble, type AssembledResult } from './assemble.ts'
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
const contexts: Context[] = []
let identityHome: string
beforeEach(async () => {
identityHome = await mkdtemp(join(tmpdir(), 'dsh-e2e-user-id-'))
vi.stubEnv('DSH_HOME', identityHome)
})
async function harness(_model: string, config: Partial<Config> = {}) {
const ctx = new Context()
@@ -30,6 +36,8 @@ async function harness(_model: string, config: Partial<Config> = {}) {
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
vi.unstubAllEnvs()
await rm(identityHome, { recursive: true, force: true })
})
function ask(text: string): Message[] {

View File

@@ -1,4 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
import LlmService, { createUserMessage,
@@ -9,6 +12,7 @@ import LlmService, { createUserMessage,
userAgent,
} from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-user-id'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
@@ -17,10 +21,19 @@ import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
import type { Behavior } from './mock-server.ts'
const TEST_USER_ID = '00000000-0000-4000-8000-000000000001' as AnonymousUserId
let testHome: string
beforeEach(() => {
testHome = mkdtempSync(join(tmpdir(), 'dsh-llm-deepseek-'))
vi.stubEnv('DSH_HOME', testHome)
})
afterEach(async () => {
await closeMockServers()
vi.unstubAllEnvs()
vi.useRealTimers()
rmSync(testHome, { recursive: true, force: true })
})
async function harness(baseURL: string, config: object = {}) {
@@ -39,6 +52,7 @@ function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {
return new DeepSeekAdapter({
options: () => resolveAdapterOptions(rest),
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
resolveUserId: () => TEST_USER_ID,
})
}
@@ -66,9 +80,10 @@ describe('DeepSeekAdapter against a mock server', () => {
stream: true,
stream_options: { include_usage: true },
})
// Attribution reaches the wire: the exact shared User-Agent, and no
// provider-specific headers under the User-Agent-only contract.
// App attribution and DeepSeek request identity are independent wire facts.
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-session-id')
expect(server.headers[0]).not.toHaveProperty('http-referer')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')
expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
@@ -93,7 +108,7 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
})
it('forwards the harness session id for host-side trajectory routing', async () => {
it('forwards the harness user and session ids for host-side trajectory routing', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
@@ -107,6 +122,7 @@ describe('DeepSeekAdapter against a mock server', () => {
})
expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
expect(server.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
})
it('marks the auxiliary compaction call on the wire', async () => {
@@ -997,12 +1013,14 @@ describe('plugin registration and config', () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url }))
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
const resolveUserId = vi.fn(() => TEST_USER_ID)
const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })
for await (const _chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { /* drain */ }
expect(options).toHaveBeenCalledTimes(1)
expect(resolveApiKey).toHaveBeenCalledTimes(1)
expect(resolveUserId).toHaveBeenCalledTimes(1)
expect(server.headers[0]?.authorization).toBe('Bearer per-request-key')
})

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
@@ -41,6 +41,7 @@ interface Harness {
* file watching is the providers' own covered concern.
*/
async function boot(dir: string, config: object): Promise<Harness> {
vi.stubEnv('DSH_HOME', dir)
const ctx = new Context()
cleanups.push(async () => {
await ctx.fiber.dispose()
@@ -86,9 +87,11 @@ describe('request-level dynamic configuration', () => {
const keyless = await prompt(ctx)
expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
await expect(access(join(dir, '.userid'))).rejects.toMatchObject({ code: 'ENOENT' })
await ctx.credentials.set(KEY_REF, 'sk-arrived')
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
await expect(access(join(dir, '.userid'))).resolves.toBeUndefined()
})
it('rejects a stored credential no header can carry, never echoing it in the failure', async () => {

View File

@@ -21,6 +21,7 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
@@ -47,6 +48,7 @@ async function loadComposition(
// exactly as the previous process left them.
const fresh = options.reuseRoot === undefined
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
vi.stubEnv('DSH_HOME', root)
const settingsPath = join(root, 'settings.yaml')
const credentialsPath = join(root, '.credentials.yaml')
if (options.withDynamic && fresh) {
@@ -115,6 +117,7 @@ describe('llm-deepseek real dynamic composition', () => {
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key')
expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
// External edits, exactly as a user or the web UI would leave them on disk.
await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`)