{"version":3,"sources":["../../src/llm/llm.test.ts"],"sourcesContent":["// SPDX-FileCopyrightText: 2026 LiveKit, Inc.\n//\n// SPDX-License-Identifier: Apache-2.0\nimport { beforeAll, describe, expect, it, vi } from 'vitest';\nimport { initializeLogger } from '../log.js';\nimport type { LLMMetrics } from '../metrics/base.js';\nimport { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js';\nimport { Future, Task, delay } from '../utils.js';\nimport { ChatContext, FunctionCall } from './chat_context.js';\nimport { type ChatChunk, LLM, LLMStream } from './llm.js';\nimport type { ToolChoice, ToolCtxInput } from './tool_context.js';\n\nclass MockLLMStream extends LLMStream {\n  constructor(\n    llm: LLM,\n    opts: {\n      chatCtx: ChatContext;\n      toolCtx?: ToolCtxInput;\n      connOptions: APIConnectOptions;\n    },\n    private chunks: ChatChunk[],\n  ) {\n    super(llm, opts);\n  }\n\n  protected async run(): Promise<void> {\n    for (const chunk of this.chunks) {\n      this.queue.put(chunk);\n      await delay(1);\n    }\n  }\n}\n\nclass MockLLM extends LLM {\n  constructor(private chunks: ChatChunk[]) {\n    super();\n  }\n\n  label(): string {\n    return 'mock-llm';\n  }\n\n  chat(opts: {\n    chatCtx: ChatContext;\n    toolCtx?: ToolCtxInput;\n    connOptions?: APIConnectOptions;\n    parallelToolCalls?: boolean;\n    toolChoice?: ToolChoice;\n    extraKwargs?: Record<string, unknown>;\n  }): LLMStream {\n    return new MockLLMStream(\n      this,\n      {\n        chatCtx: opts.chatCtx,\n        toolCtx: opts.toolCtx,\n        connOptions: opts.connOptions ?? DEFAULT_API_CONNECT_OPTIONS,\n      },\n      this.chunks,\n    );\n  }\n}\n\nclass PrewarmLLM extends MockLLM {\n  constructor(private readonly prewarmImpl: (signal: AbortSignal) => Promise<void>) {\n    super([]);\n  }\n\n  protected override _prewarmImpl(signal: AbortSignal): Promise<void> {\n    return this.prewarmImpl(signal);\n  }\n}\n\nconst waitForTasks = () => new Promise<void>((resolve) => setImmediate(resolve));\n\nasync function collectMetrics(llm: MockLLM): Promise<LLMMetrics> {\n  const metrics = new Promise<LLMMetrics>((resolve) => llm.once('metrics_collected', resolve));\n  await llm.chat({ chatCtx: new ChatContext() }).collect();\n  return metrics;\n}\n\ndescribe('LLMStream metrics', () => {\n  it('defaults cache creation tokens to zero', async () => {\n    const metrics = await collectMetrics(new MockLLM([]));\n\n    expect(metrics.cacheCreationTokens).toBe(0);\n  });\n\n  it('carries cache creation tokens', async () => {\n    const metrics = await collectMetrics(\n      new MockLLM([\n        {\n          id: '1',\n          usage: {\n            completionTokens: 10,\n            promptTokens: 100,\n            promptCachedTokens: 20,\n            cacheCreationTokens: 42,\n            totalTokens: 110,\n          },\n        },\n      ]),\n    );\n\n    expect(metrics.cacheCreationTokens).toBe(42);\n  });\n});\n\ndescribe('LLM prewarm lifecycle', () => {\n  it('is a no-op when the provider does not override _prewarmImpl', async () => {\n    const taskFrom = vi.spyOn(Task, 'from');\n    const llm = new MockLLM([]);\n\n    try {\n      llm.prewarm();\n      await llm.aclose();\n\n      expect(taskFrom).not.toHaveBeenCalled();\n    } finally {\n      taskFrom.mockRestore();\n    }\n  });\n\n  it('schedules fire-and-forget work without surfacing provider rejection', async () => {\n    const unhandledRejection = vi.fn();\n    process.on('unhandledRejection', unhandledRejection);\n    const llm = new PrewarmLLM(async () => {\n      throw new Error('provider unavailable');\n    });\n\n    try {\n      expect(() => llm.prewarm()).not.toThrow();\n      await waitForTasks();\n\n      expect(unhandledRejection).not.toHaveBeenCalled();\n    } finally {\n      process.off('unhandledRejection', unhandledRejection);\n    }\n  });\n\n  it('invokes the provider hook once across repeated calls, including after failure', async () => {\n    const prewarmImpl = vi.fn(async () => {\n      throw new Error('provider unavailable');\n    });\n    const llm = new PrewarmLLM(prewarmImpl);\n\n    llm.prewarm();\n    llm.prewarm();\n    await waitForTasks();\n    llm.prewarm();\n    await waitForTasks();\n\n    expect(prewarmImpl).toHaveBeenCalledTimes(1);\n  });\n\n  it('aborts an in-flight prewarm and waits for provider cleanup during close', async () => {\n    const started = new Future<void>();\n    let signal: AbortSignal | undefined;\n    let cleanedUp = false;\n    const llm = new PrewarmLLM(\n      (prewarmSignal) =>\n        new Promise<void>((resolve) => {\n          signal = prewarmSignal;\n          started.resolve();\n          prewarmSignal.addEventListener(\n            'abort',\n            () => {\n              setImmediate(() => {\n                cleanedUp = true;\n                resolve();\n              });\n            },\n            { once: true },\n          );\n        }),\n    );\n\n    llm.prewarm();\n    await started.await;\n    const closing = llm.aclose();\n\n    expect(signal?.aborted).toBe(true);\n    expect(cleanedUp).toBe(false);\n\n    await closing;\n    expect(cleanedUp).toBe(true);\n  });\n\n  it('does not start prewarm work after close wins the lifecycle race', async () => {\n    const prewarmImpl = vi.fn(async () => {});\n    const llm = new PrewarmLLM(prewarmImpl);\n\n    await llm.aclose();\n    llm.prewarm();\n    await waitForTasks();\n\n    expect(prewarmImpl).not.toHaveBeenCalled();\n  });\n});\n\ndescribe('LLMStream.collect', () => {\n  beforeAll(() => {\n    initializeLogger({ pretty: false });\n    process.on('unhandledRejection', () => {});\n  });\n\n  it('joins content parts and trims surrounding whitespace', async () => {\n    const llm = new MockLLM([\n      { id: '1', delta: { role: 'assistant', content: '  Hello' } },\n      { id: '1', delta: { role: 'assistant', content: ', ' } },\n      { id: '1', delta: { role: 'assistant', content: 'world!  ' } },\n    ]);\n\n    const response = await llm.chat({ chatCtx: new ChatContext() }).collect();\n\n    expect(response.text).toBe('Hello, world!');\n    expect(response.toolCalls).toHaveLength(0);\n    expect(response.usage).toBeUndefined();\n    expect(response.extra).toEqual({});\n  });\n\n  it('accumulates tool calls across chunks', async () => {\n    const callA = new FunctionCall({\n      callId: 'call_a',\n      name: 'get_weather',\n      args: '{\"city\":\"SF\"}',\n    });\n    const callB = new FunctionCall({\n      callId: 'call_b',\n      name: 'play_song',\n      args: '{\"name\":\"x\"}',\n    });\n    const llm = new MockLLM([\n      { id: '1', delta: { role: 'assistant', toolCalls: [callA] } },\n      { id: '1', delta: { role: 'assistant', toolCalls: [callB] } },\n    ]);\n\n    const response = await llm.chat({ chatCtx: new ChatContext() }).collect();\n\n    expect(response.text).toBe('');\n    expect(response.toolCalls).toHaveLength(2);\n    expect(response.toolCalls[0]!.callId).toBe('call_a');\n    expect(response.toolCalls[1]!.callId).toBe('call_b');\n  });\n\n  it('captures the latest usage and merges extra data', async () => {\n    const llm = new MockLLM([\n      { id: '1', delta: { role: 'assistant', content: 'hi', extra: { a: 1 } } },\n      {\n        id: '1',\n        delta: { role: 'assistant', content: ' there', extra: { b: 2 } },\n        usage: {\n          completionTokens: 2,\n          promptTokens: 5,\n          promptCachedTokens: 0,\n          totalTokens: 7,\n        },\n      },\n      {\n        id: '1',\n        usage: {\n          completionTokens: 3,\n          promptTokens: 5,\n          promptCachedTokens: 0,\n          totalTokens: 8,\n        },\n      },\n    ]);\n\n    const response = await llm.chat({ chatCtx: new ChatContext() }).collect();\n\n    expect(response.text).toBe('hi there');\n    expect(response.usage?.completionTokens).toBe(3);\n    expect(response.usage?.totalTokens).toBe(8);\n    expect(response.extra).toEqual({ a: 1, b: 2 });\n  });\n\n  it('returns empty response for an empty stream', async () => {\n    const llm = new MockLLM([]);\n\n    const response = await llm.chat({ chatCtx: new ChatContext() }).collect();\n\n    expect(response.text).toBe('');\n    expect(response.toolCalls).toHaveLength(0);\n    expect(response.usage).toBeUndefined();\n    expect(response.extra).toEqual({});\n  });\n});\n"],"mappings":"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","names":[]}