adapter.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. import { createServer } from 'node:http'
  2. import type { IncomingMessage, Server, ServerResponse } from 'node:http'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { Context } from 'cordis'
  5. import LlmService, {
  6. CONTEXT_WINDOW_EXCEEDED_CODE,
  7. errorChain,
  8. LlmError,
  9. ProviderRequestId,
  10. QUOTA_EXCEEDED_CODE,
  11. ReasoningEffortId,
  12. userAgent,
  13. } from '@deepseek-ai/dsh-llm'
  14. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  15. import { SessionId } from '@deepseek-ai/dsh-session'
  16. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  17. import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
  18. import { httpErrorCode } from '../src/adapter.ts'
  19. import { assemble } from './assemble.ts'
  20. /** One scripted behavior for the next request the mock server receives. */
  21. type Behavior =
  22. | { kind: 'sse'; events: string[]; delayMs?: number }
  23. | { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
  24. | { kind: 'close-early'; events: string[] }
  25. interface MockServer {
  26. url: string
  27. /** Bodies of received requests, in order. */
  28. requests: unknown[]
  29. /** Header bags of received requests, in order (parallel to `requests`). */
  30. headers: IncomingMessage['headers'][]
  31. script: Behavior[]
  32. close(): Promise<void>
  33. }
  34. const servers: Server[] = []
  35. afterEach(async () => {
  36. await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
  37. vi.unstubAllEnvs()
  38. vi.useRealTimers()
  39. })
  40. /** Local chat-completions stand-in: replays scripted behaviors per request. */
  41. async function mockServer(script: Behavior[]): Promise<MockServer> {
  42. const requests: unknown[] = []
  43. const headers: IncomingMessage['headers'][] = []
  44. const server = createServer((request: IncomingMessage, response: ServerResponse) => {
  45. let body = ''
  46. request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
  47. request.on('end', () => {
  48. requests.push(JSON.parse(body))
  49. headers.push(request.headers)
  50. const behavior = script.shift()
  51. if (!behavior) {
  52. response.writeHead(500).end('mock script exhausted')
  53. return
  54. }
  55. if (behavior.kind === 'http-error') {
  56. response.writeHead(behavior.status, {
  57. 'content-type': behavior.contentType ?? 'application/json',
  58. ...behavior.headers,
  59. })
  60. response.end(behavior.body)
  61. return
  62. }
  63. response.writeHead(200, { 'content-type': 'text/event-stream' })
  64. const write = (index: number): void => {
  65. if (index >= behavior.events.length) {
  66. if (behavior.kind === 'sse') response.end()
  67. else response.destroy() // close-early: drop the socket mid-stream
  68. return
  69. }
  70. response.write(`data: ${behavior.events[index]}\n\n`)
  71. setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
  72. }
  73. write(0)
  74. })
  75. })
  76. servers.push(server)
  77. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  78. const address = server.address()
  79. if (address === null || typeof address === 'string') throw new Error('no port')
  80. return {
  81. url: `http://127.0.0.1:${address.port}`,
  82. requests,
  83. headers,
  84. script,
  85. close: () => new Promise(resolve => server.close(() => { resolve() })),
  86. }
  87. }
  88. const textEvents = [
  89. '{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
  90. '{"choices":[{"delta":{"content":"hello"}}]}',
  91. '{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
  92. '[DONE]',
  93. ]
  94. async function harness(baseURL: string, config: object = {}) {
  95. const ctx = new Context()
  96. await ctx.plugin(LlmService)
  97. await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
  98. return ctx
  99. }
  100. describe('DeepSeekAdapter against a mock server', () => {
  101. it('streams a text generation end to end through the assembler', async () => {
  102. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  103. const ctx = await harness(server.url)
  104. const result = await assemble(ctx, {
  105. model: 'deepseek-v4-flash',
  106. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  107. })
  108. expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
  109. expect(result.finish).toEqual({ kind: 'stop' })
  110. expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 1 })
  111. // The wire request carried the auth header contents we configured.
  112. expect(server.requests[0]).toMatchObject({
  113. model: 'deepseek-v4-flash',
  114. reasoning_effort: 'high',
  115. stream: true,
  116. stream_options: { include_usage: true },
  117. })
  118. // Attribution reaches the wire: the exact shared User-Agent, and no
  119. // provider-specific headers under the User-Agent-only contract.
  120. expect(server.headers[0]?.['user-agent']).toBe(userAgent())
  121. expect(server.headers[0]).not.toHaveProperty('http-referer')
  122. expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')
  123. expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
  124. expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact')
  125. })
  126. it('streams raw chunks through ctx.llm.stream', async () => {
  127. const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }])
  128. const ctx = await harness(server.url)
  129. const kinds: string[] = []
  130. for await (const chunk of ctx.llm.stream({
  131. provider: 'deepseek',
  132. model: 'deepseek-v4-flash',
  133. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  134. })) {
  135. kinds.push(chunk.type)
  136. }
  137. expect(kinds).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish'])
  138. })
  139. it('forwards the harness session id for host-side trajectory routing', async () => {
  140. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  141. const ctx = await harness(server.url)
  142. await assemble(ctx, {
  143. model: 'deepseek-v4-flash',
  144. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  145. sessionId: SessionId('child-session'),
  146. })
  147. expect(server.headers[0]?.['x-deepseek-harness-session-id']).toBe('child-session')
  148. })
  149. it('marks the auxiliary compaction call on the wire', async () => {
  150. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  151. const ctx = await harness(server.url)
  152. await assemble(ctx, {
  153. model: 'deepseek-v4-flash',
  154. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  155. purpose: 'compaction',
  156. })
  157. expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1')
  158. })
  159. it('switches dynamically from the configured high default through off to max', async () => {
  160. const server = await mockServer([
  161. { kind: 'sse', events: textEvents },
  162. { kind: 'sse', events: textEvents },
  163. { kind: 'sse', events: textEvents },
  164. ])
  165. const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'high' })
  166. await assemble(ctx,{
  167. model: 'deepseek-v4-flash',
  168. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  169. })
  170. await assemble(ctx,{
  171. model: 'deepseek-v4-flash',
  172. reasoningEffort: ReasoningEffortId('off'),
  173. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }],
  174. })
  175. await assemble(ctx,{
  176. model: 'deepseek-v4-flash',
  177. reasoningEffort: ReasoningEffortId('max'),
  178. messages: [{ role: 'user', content: [{ type: 'text', text: 'one more time' }] }],
  179. })
  180. expect(server.requests[0]).toMatchObject({
  181. thinking: { type: 'enabled' },
  182. reasoning_effort: 'high',
  183. })
  184. expect(server.requests[1]).toMatchObject({
  185. thinking: { type: 'disabled' },
  186. })
  187. expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
  188. expect(server.requests[2]).toMatchObject({
  189. thinking: { type: 'enabled' },
  190. reasoning_effort: 'max',
  191. })
  192. })
  193. it('publishes only off and omits the wire effort when thinking is disabled', async () => {
  194. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  195. const ctx = await harness(server.url, { thinking: 'disabled' })
  196. await assemble(ctx,{
  197. model: 'deepseek-v4-flash',
  198. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  199. })
  200. expect(server.requests[0]).toMatchObject({
  201. thinking: { type: 'disabled' },
  202. })
  203. expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
  204. await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
  205. .resolves.toMatchObject({
  206. reasoning: {
  207. efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
  208. defaultEffort: ReasoningEffortId('off'),
  209. },
  210. })
  211. })
  212. it('rejects a per-request effort before I/O when thinking is disabled', async () => {
  213. const server = await mockServer([])
  214. const ctx = await harness(server.url, { thinking: 'disabled' })
  215. await expect(assemble(ctx, {
  216. model: 'deepseek-v4-flash',
  217. reasoningEffort: ReasoningEffortId('high'),
  218. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  219. })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
  220. expect(server.requests).toHaveLength(0)
  221. })
  222. it.each(['high', 'max'])(
  223. 'rejects direct adapter effort %s before I/O when thinking is disabled',
  224. async (effort) => {
  225. const server = await mockServer([])
  226. const adapter = new DeepSeekAdapter({
  227. apiKey: 'test-key',
  228. baseURL: server.url,
  229. defaults: { thinking: 'disabled' },
  230. })
  231. const stream = adapter.stream({
  232. provider: 'deepseek',
  233. model: 'deepseek-v4-flash',
  234. reasoningEffort: ReasoningEffortId(effort),
  235. messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
  236. })
  237. await expect(async () => {
  238. for await (const _chunk of stream) { /* drain */ }
  239. }).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
  240. expect(server.requests).toHaveLength(0)
  241. },
  242. )
  243. it.each([
  244. [401, 'AUTH'],
  245. [403, 'AUTH'],
  246. [429, 'RATE_LIMIT'],
  247. [400, 'INVALID_REQUEST'],
  248. [500, 'SERVER'],
  249. [503, 'SERVER'],
  250. ])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
  251. const behavior: Behavior = {
  252. kind: 'http-error',
  253. status,
  254. body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
  255. }
  256. const server = await mockServer([behavior, behavior])
  257. const ctx = await harness(server.url)
  258. await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
  259. .rejects.toThrow(`failed with ${status}`)
  260. await expect(
  261. assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
  262. .catch((error: unknown) => (error as LlmError).code),
  263. ).resolves.toBe(code)
  264. })
  265. it('classifies a thrown HTTP context-window rejection with the canonical code', async () => {
  266. const server = await mockServer([{
  267. kind: 'http-error',
  268. status: 400,
  269. body: JSON.stringify({
  270. error: {
  271. message: 'This model maximum context length is 128000 tokens; your input exceeds that limit.',
  272. type: 'invalid_request_error',
  273. code: 'context_length_exceeded',
  274. },
  275. }),
  276. }])
  277. const ctx = await harness(server.url)
  278. const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  279. .catch((error: unknown) => (error as LlmError).code)
  280. expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
  281. })
  282. it('retains status, Retry-After seconds, and provider request id as structured facts', async () => {
  283. const server = await mockServer([{
  284. kind: 'http-error',
  285. status: 429,
  286. body: JSON.stringify({ error: { message: 'slow down' } }),
  287. headers: { 'retry-after': '2', 'x-request-id': 'req-429' },
  288. }])
  289. const ctx = await harness(server.url)
  290. let thrown: unknown
  291. try {
  292. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  293. } catch (error: unknown) {
  294. thrown = error
  295. }
  296. expect(thrown).toBeInstanceOf(LlmError)
  297. expect((thrown as LlmError).failure).toEqual({
  298. message: 'slow down',
  299. code: 'RATE_LIMIT',
  300. status: 429,
  301. providerRetryAfterMs: 2_000,
  302. requestId: ProviderRequestId('req-429'),
  303. })
  304. })
  305. it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => {
  306. const now = 1_800_000_000_000
  307. const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
  308. try {
  309. const server = await mockServer([{
  310. kind: 'http-error',
  311. status: 503,
  312. body: JSON.stringify({ error: { message: 'come back later' } }),
  313. headers: {
  314. 'retry-after': new Date(now + 3_000).toUTCString(),
  315. 'x-deepseek-request-id': 'deepseek-503',
  316. },
  317. }])
  318. const ctx = await harness(server.url)
  319. await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
  320. .rejects.toMatchObject({
  321. failure: {
  322. message: 'come back later',
  323. code: 'SERVER',
  324. status: 503,
  325. providerRetryAfterMs: 3_000,
  326. requestId: ProviderRequestId('deepseek-503'),
  327. },
  328. })
  329. } finally {
  330. dateNow.mockRestore()
  331. }
  332. })
  333. it('omits zero, non-finite, invalid, and past Retry-After values', async () => {
  334. const values = [
  335. '0',
  336. '9'.repeat(400),
  337. 'not-a-date',
  338. new Date(0).toUTCString(),
  339. ]
  340. for (const value of values) {
  341. const server = await mockServer([{
  342. kind: 'http-error',
  343. status: 429,
  344. body: JSON.stringify({ error: { message: 'retry later' } }),
  345. headers: { 'retry-after': value },
  346. }])
  347. const ctx = await harness(server.url)
  348. let thrown: LlmError | undefined
  349. try {
  350. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  351. } catch (error: unknown) {
  352. if (error instanceof LlmError) thrown = error
  353. }
  354. expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 })
  355. }
  356. })
  357. it('classifies only context-capacity HTTP 400 details as context overflow', () => {
  358. expect(httpErrorCode(400, { message: 'request too large for model context' }))
  359. .toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
  360. expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' }))
  361. .toBe('INVALID_REQUEST')
  362. expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413')
  363. })
  364. it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => {
  365. expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' }))
  366. .toBe(QUOTA_EXCEEDED_CODE)
  367. expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT')
  368. })
  369. it('keeps the status-line message for JSON error bodies without a message', async () => {
  370. const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
  371. const ctx = await harness(server.url)
  372. await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
  373. .rejects.toThrow(/HTTP 500/)
  374. })
  375. it('keeps the status-line message for non-JSON error bodies', async () => {
  376. const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
  377. const ctx = await harness(server.url)
  378. await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
  379. .rejects.toThrow(/HTTP 502/)
  380. })
  381. it('maps unusual statuses to HTTP_<status>', () => {
  382. expect(httpErrorCode(418)).toBe('HTTP_418')
  383. })
  384. it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => {
  385. // Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
  386. // whose actionable detail (ECONNREFUSED) lives on `cause`.
  387. const ctx = await harness('http://127.0.0.1:1')
  388. let caught: unknown
  389. try {
  390. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  391. } catch (error: unknown) {
  392. caught = error
  393. }
  394. expect(caught).toBeInstanceOf(LlmError)
  395. const llmError = caught as LlmError
  396. expect(llmError.code).toBe('TRANSPORT')
  397. expect(llmError.message).toContain('http://127.0.0.1:1')
  398. expect(llmError.cause).toBeInstanceOf(TypeError)
  399. // The chain renderer reaches the transport diagnosis through the cause.
  400. expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
  401. })
  402. it('classifies an aborted request without losing the transport rejection', async () => {
  403. const controller = new AbortController()
  404. controller.abort()
  405. const ctx = await harness('http://127.0.0.1:1')
  406. let caught: unknown
  407. try {
  408. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
  409. } catch (error: unknown) {
  410. caught = error
  411. }
  412. expect(caught).toBeInstanceOf(LlmError)
  413. expect(caught).toMatchObject({ code: 'ABORTED' })
  414. expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' })
  415. })
  416. it('throws EMPTY_RESPONSE when the response has no body', async () => {
  417. const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
  418. const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
  419. new Response(null, { status: 200 }),
  420. )
  421. try {
  422. const iterate = async (): Promise<void> => {
  423. for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
  424. }
  425. await expect(iterate()).rejects.toThrow(/no response body/)
  426. } finally {
  427. fetchSpy.mockRestore()
  428. }
  429. })
  430. it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => {
  431. const server = await mockServer([{
  432. kind: 'close-early',
  433. events: ['{"choices":[{"delta":{"content":"par"}}]}'],
  434. }])
  435. const ctx = await harness(server.url)
  436. let caught: unknown
  437. try {
  438. await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
  439. } catch (error: unknown) {
  440. caught = error
  441. }
  442. expect(caught).toMatchObject({ code: 'TRANSPORT' })
  443. expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/)
  444. })
  445. it('aborts mid-stream via the request signal', async () => {
  446. const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 50 }])
  447. const ctx = await harness(server.url)
  448. const controller = new AbortController()
  449. const pending = (async () => {
  450. const chunks = []
  451. for await (const chunk of ctx.llm.stream({
  452. provider: 'deepseek',
  453. model: 'deepseek-v4-flash',
  454. messages: [],
  455. signal: controller.signal,
  456. })) {
  457. chunks.push(chunk)
  458. }
  459. return chunks
  460. })()
  461. setTimeout(() => { controller.abort() }, 30)
  462. await expect(pending).rejects.toMatchObject({ code: 'ABORTED' })
  463. })
  464. it('maps connection failures to TRANSPORT without losing the cause', async () => {
  465. const cause = new TypeError('connection refused')
  466. const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
  467. const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
  468. try {
  469. const drain = async (): Promise<void> => {
  470. for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
  471. }
  472. await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause })
  473. } finally {
  474. fetchSpy.mockRestore()
  475. }
  476. })
  477. it('renders a non-Error transport rejection without losing its cause', async () => {
  478. const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
  479. const failed = Promise.withResolvers<Response>()
  480. failed.reject('offline')
  481. return failed.promise
  482. })
  483. const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
  484. try {
  485. const drain = async (): Promise<void> => {
  486. for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
  487. }
  488. await expect(drain()).rejects.toMatchObject({
  489. message: 'DeepSeek API request to https://example.invalid failed',
  490. code: 'TRANSPORT',
  491. cause: 'offline',
  492. })
  493. } finally {
  494. fetchSpy.mockRestore()
  495. }
  496. })
  497. it('aborts the underlying body when the stream stays idle past its watchdog', async () => {
  498. vi.useFakeTimers()
  499. let stopped = false
  500. const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => {
  501. const signal = init?.signal
  502. const body = new ReadableStream<Uint8Array>({
  503. start(controller) {
  504. signal?.addEventListener('abort', () => {
  505. stopped = true
  506. controller.error(signal.reason)
  507. }, { once: true })
  508. },
  509. })
  510. return Promise.resolve(new Response(body, { status: 200 }))
  511. })
  512. const adapter = new DeepSeekAdapter({
  513. apiKey: 'k',
  514. baseURL: 'https://example.invalid',
  515. streamIdleTimeoutMs: 100,
  516. })
  517. try {
  518. const drain = (async () => {
  519. for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
  520. })()
  521. const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' })
  522. await vi.advanceTimersByTimeAsync(0)
  523. await vi.advanceTimersByTimeAsync(100)
  524. await rejected
  525. expect(stopped).toBe(true)
  526. } finally {
  527. fetchSpy.mockRestore()
  528. }
  529. })
  530. })
  531. describe('plugin registration and config', () => {
  532. it('keeps wire helpers off the package root', () => {
  533. for (const helper of [
  534. 'httpErrorCode',
  535. 'serializeMessages',
  536. 'serializeRequest',
  537. 'DONE',
  538. 'parseSse',
  539. 'mapFinishReason',
  540. 'mapUsage',
  541. 'translate',
  542. ]) expect(LlmDeepSeek).not.toHaveProperty(helper)
  543. })
  544. it('registers the deepseek provider and unregisters on dispose (HMR safety)', async () => {
  545. const server = await mockServer([])
  546. const ctx = new Context()
  547. await ctx.plugin(LlmService)
  548. const fiber = await ctx.plugin(LlmDeepSeek, {
  549. apiKey: 'k',
  550. baseURL: server.url,
  551. })
  552. expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
  553. await fiber.dispose()
  554. expect(ctx.llm.listProviders()).toEqual([])
  555. })
  556. it('owns the deepseek provider and advertises the default models', async () => {
  557. const ctx = new Context()
  558. await ctx.plugin(LlmService)
  559. await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
  560. expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
  561. await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
  562. { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
  563. { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
  564. ])
  565. await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
  566. .resolves.toMatchObject({
  567. provider: 'deepseek',
  568. id: 'deepseek-v4-flash',
  569. name: 'deepseek-v4-flash',
  570. context: { contextWindow: 128_000 },
  571. reasoning: {
  572. efforts: [
  573. { id: ReasoningEffortId('off'), name: 'Off' },
  574. { id: ReasoningEffortId('high'), name: 'High' },
  575. { id: ReasoningEffortId('max'), name: 'Max' },
  576. ],
  577. defaultEffort: ReasoningEffortId('high'),
  578. },
  579. })
  580. })
  581. it.each(['off', 'max'] as const)('uses the configured %s reasoning default', async (effort) => {
  582. const ctx = new Context()
  583. await ctx.plugin(LlmService)
  584. await ctx.plugin(LlmDeepSeek, {
  585. apiKey: 'k',
  586. baseURL: 'http://127.0.0.1:1',
  587. reasoningEffort: effort,
  588. })
  589. await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
  590. .resolves.toMatchObject({
  591. reasoning: {
  592. efforts: [
  593. { id: ReasoningEffortId('off'), name: 'Off' },
  594. { id: ReasoningEffortId('high'), name: 'High' },
  595. { id: ReasoningEffortId('max'), name: 'Max' },
  596. ],
  597. defaultEffort: ReasoningEffortId(effort),
  598. },
  599. })
  600. })
  601. it('accepts off as the default when thinking is deployment-disabled', async () => {
  602. const ctx = new Context()
  603. await ctx.plugin(LlmService)
  604. await ctx.plugin(LlmDeepSeek, {
  605. apiKey: 'k',
  606. baseURL: 'http://127.0.0.1:1',
  607. thinking: 'disabled',
  608. reasoningEffort: 'off',
  609. })
  610. await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
  611. .resolves.toMatchObject({
  612. reasoning: {
  613. efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
  614. defaultEffort: ReasoningEffortId('off'),
  615. },
  616. })
  617. })
  618. it.each(['high', 'max'] as const)(
  619. 'rejects configured reasoning effort %s when thinking is disabled',
  620. async (reasoningEffort) => {
  621. const ctx = new Context()
  622. await ctx.plugin(LlmService)
  623. await expect(ctx.plugin(LlmDeepSeek, {
  624. apiKey: 'k',
  625. baseURL: 'http://127.0.0.1:1',
  626. thinking: 'disabled',
  627. reasoningEffort,
  628. })).rejects.toThrow(/only reasoningEffort "off"/)
  629. expect(ctx.llm.listProviders()).toEqual([])
  630. },
  631. )
  632. it.each(['high', 'max'] as const)(
  633. 'rejects disabled-thinking effort %s at the direct constructor boundary',
  634. (reasoningEffort) => {
  635. expect(() => new DeepSeekAdapter({
  636. apiKey: 'k',
  637. baseURL: 'http://127.0.0.1:1',
  638. defaults: { thinking: 'disabled', reasoningEffort },
  639. })).toThrow(/only reasoningEffort "off"/)
  640. },
  641. )
  642. it('accepts disabled thinking with off at the direct constructor boundary', async () => {
  643. const adapter = new DeepSeekAdapter({
  644. apiKey: 'k',
  645. baseURL: 'http://127.0.0.1:1',
  646. defaults: { thinking: 'disabled', reasoningEffort: 'off' },
  647. })
  648. await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
  649. reasoning: {
  650. efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
  651. defaultEffort: ReasoningEffortId('off'),
  652. },
  653. })
  654. })
  655. it('uses the default model catalog when apply is called directly', async () => {
  656. const ctx = new Context()
  657. await ctx.plugin(LlmService)
  658. LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
  659. await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
  660. { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
  661. { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
  662. ])
  663. })
  664. it('advertises configured models without restricting arbitrary request ids', async () => {
  665. const ctx = new Context()
  666. await ctx.plugin(LlmService)
  667. await ctx.plugin(LlmDeepSeek, {
  668. apiKey: 'k',
  669. baseURL: 'http://127.0.0.1:1',
  670. models: [
  671. { id: 'private-fast', contextWindow: 32_000 },
  672. {
  673. id: 'private-reasoner',
  674. name: 'Private Reasoner',
  675. description: 'Higher reasoning budget',
  676. contextWindow: 64_000,
  677. },
  678. ],
  679. })
  680. await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
  681. { provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
  682. { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
  683. ])
  684. await expect(ctx.llm.resolveModelInfo('deepseek', 'private-fast'))
  685. .resolves.toMatchObject({ context: { contextWindow: 32_000 } })
  686. await expect(ctx.llm.resolveModelInfo('deepseek', 'private-reasoner'))
  687. .resolves.toMatchObject({
  688. name: 'Private Reasoner',
  689. description: 'Higher reasoning budget',
  690. })
  691. await expect(ctx.llm.resolveModelInfo('deepseek', 'arbitrary-unlisted'))
  692. .resolves.not.toHaveProperty('context')
  693. })
  694. it('uses exact model capacity before the adapter-wide default', async () => {
  695. const ctx = new Context()
  696. await ctx.plugin(LlmService)
  697. await ctx.plugin(LlmDeepSeek, {
  698. apiKey: 'k',
  699. baseURL: 'http://127.0.0.1:1',
  700. defaultContextWindow: 256_000,
  701. models: [
  702. { id: 'inherits-default' },
  703. { id: 'exact-override', contextWindow: 64_000 },
  704. ],
  705. })
  706. await expect(ctx.llm.resolveModelInfo('deepseek', 'inherits-default'))
  707. .resolves.toMatchObject({ context: { contextWindow: 256_000 } })
  708. await expect(ctx.llm.resolveModelInfo('deepseek', 'exact-override'))
  709. .resolves.toMatchObject({ context: { contextWindow: 64_000 } })
  710. await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted-pass-through'))
  711. .resolves.toMatchObject({ context: { contextWindow: 256_000 } })
  712. })
  713. it('allows an explicit empty model catalog', async () => {
  714. const ctx = new Context()
  715. await ctx.plugin(LlmService)
  716. await ctx.plugin(LlmDeepSeek, {
  717. apiKey: 'k',
  718. baseURL: 'http://127.0.0.1:1',
  719. models: [],
  720. })
  721. await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([])
  722. })
  723. it.each([
  724. [[{ id: '' }], /ids must be non-empty/],
  725. [[{ id: 'm', name: '' }], /empty name/],
  726. [[{ id: 'm', contextWindow: 0 }], /contextWindow/],
  727. [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/],
  728. [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/],
  729. ] as const)('rejects invalid advisory model config', async (models, message) => {
  730. const ctx = new Context()
  731. await ctx.plugin(LlmService)
  732. await expect(ctx.plugin(LlmDeepSeek, {
  733. apiKey: 'k',
  734. baseURL: 'http://127.0.0.1:1',
  735. models: [...models],
  736. })).rejects.toThrow(message)
  737. expect(ctx.llm.listProviders()).toEqual([])
  738. })
  739. it('rejects invalid context capacity when apply is called directly', async () => {
  740. const ctx = new Context()
  741. await ctx.plugin(LlmService)
  742. expect(() => {
  743. LlmDeepSeek.apply(ctx, {
  744. apiKey: 'k',
  745. baseURL: 'http://127.0.0.1:1',
  746. models: [{ id: 'invalid-context', contextWindow: 0 }],
  747. })
  748. }).toThrow(/contextWindow must be a positive integer/)
  749. expect(ctx.llm.listProviders()).toEqual([])
  750. })
  751. it.each([0, 1.5])(
  752. 'rejects invalid adapter-wide default context capacity %s',
  753. async (defaultContextWindow) => {
  754. expect(() => new DeepSeekAdapter({
  755. apiKey: 'k',
  756. baseURL: 'http://127.0.0.1:1',
  757. defaultContextWindow,
  758. })).toThrow(/defaultContextWindow must be a positive integer/)
  759. const ctx = new Context()
  760. await ctx.plugin(LlmService)
  761. await expect(ctx.plugin(LlmDeepSeek, {
  762. apiKey: 'k',
  763. baseURL: 'http://127.0.0.1:1',
  764. defaultContextWindow,
  765. })).rejects.toThrow(/defaultContextWindow/)
  766. expect(ctx.llm.listProviders()).toEqual([])
  767. },
  768. )
  769. it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
  770. vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
  771. vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1')
  772. const ctx = new Context()
  773. await ctx.plugin(LlmService)
  774. await ctx.plugin(LlmDeepSeek, {})
  775. expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
  776. })
  777. it('throws a clear error when no API key is available', async () => {
  778. vi.stubEnv('DEEPSEEK_API_KEY', '')
  779. const ctx = new Context()
  780. await ctx.plugin(LlmService)
  781. await expect(ctx.plugin(LlmDeepSeek, {}))
  782. .rejects.toThrow(/an API key is required/)
  783. expect(ctx.llm.listProviders()).toEqual([])
  784. })
  785. it('prefers explicit config over env for key and base URL', async () => {
  786. vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
  787. vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
  788. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  789. const ctx = await harness(server.url) // harness passes explicit config
  790. await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
  791. expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
  792. })
  793. it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
  794. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  795. vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
  796. const ctx = new Context()
  797. await ctx.plugin(LlmService)
  798. await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
  799. await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
  800. expect(server.requests).toHaveLength(1)
  801. })
  802. it('defaults to the public base URL without config or env', async () => {
  803. vi.stubEnv('DEEPSEEK_API_KEY', 'k')
  804. vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
  805. const ctx = new Context()
  806. await ctx.plugin(LlmService)
  807. // Registration succeeds; no call is made (would hit api.deepseek.com).
  808. await ctx.plugin(LlmDeepSeek, {})
  809. expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
  810. })
  811. it('adapter is constructible directly for embedding', async () => {
  812. const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
  813. expect(adapter).toBeInstanceOf(DeepSeekAdapter)
  814. await expect(adapter.listModels('deepseek')).resolves.toEqual([])
  815. })
  816. it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
  817. expect(() => new DeepSeekAdapter({
  818. apiKey: 'k',
  819. baseURL: 'http://127.0.0.1:1',
  820. streamIdleTimeoutMs: Number.POSITIVE_INFINITY,
  821. })).toThrow(/streamIdleTimeoutMs.*positive finite/)
  822. expect(() => new DeepSeekAdapter({
  823. apiKey: 'k',
  824. baseURL: 'http://127.0.0.1:1',
  825. streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
  826. })).toThrow(/streamIdleTimeoutMs.*no greater/)
  827. const ctx = new Context()
  828. await ctx.plugin(LlmService)
  829. await expect(ctx.plugin(LlmDeepSeek, {
  830. apiKey: 'k',
  831. baseURL: 'http://127.0.0.1:1',
  832. streamIdleTimeoutMs: 0,
  833. })).rejects.toThrow(/streamIdleTimeoutMs/)
  834. await expect(ctx.plugin(LlmDeepSeek, {
  835. apiKey: 'k',
  836. baseURL: 'http://127.0.0.1:1',
  837. streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
  838. })).rejects.toThrow(/streamIdleTimeoutMs/)
  839. })
  840. })