diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts index bd19dd559c..662500a256 100644 --- a/packages/llm/llm-deepseek-api-key/src/index.ts +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -90,7 +90,15 @@ export function apply(ctx: Context, config: Config): void { }, connect: async (signal) => { const connection = options() - return connectionOf(connection, connection.listing === undefined ? undefined : await listings.view(connection.listing, signal)) + if (connection.listing === undefined) return connectionOf(connection, undefined) + let view: ListingView + try { view = await listings.view(connection.listing, signal) } + catch (error) { + if (signal?.aborted === true) throw error + // A credential lookup failure is reported by `resolveAuth` on the model request, after the turn records the prompt. + view = listings.current() + } + return connectionOf(connection, view) }, resolveAuth: async connection => ({ headers: { 'x-api-key': await resolveApiKey(connection) } }), admit: (connection, model) => { refuseListedModel(model, connection.refusals.get(model)) }, diff --git a/packages/llm/llm-deepseek-api-key/tests/listing.spec.ts b/packages/llm/llm-deepseek-api-key/tests/listing.spec.ts index 9bfd1bf07c..fb6364e9dd 100644 --- a/packages/llm/llm-deepseek-api-key/tests/listing.spec.ts +++ b/packages/llm/llm-deepseek-api-key/tests/listing.spec.ts @@ -100,6 +100,21 @@ it('refuses listed models this build cannot serve before any inference request', expect(requests).toHaveLength(1) }) +it('lets a caller abandon the first listing wait', async () => { + const started = Promise.withResolvers() + vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => new Promise((_resolve, reject) => { + started.resolve(undefined) + init?.signal?.addEventListener('abort', () => { reject(init.signal?.reason) }, { once: true }) + })) + const { ctx } = await host() + await ctx.plugin(ApiKey, {}) + const caller = new AbortController() + const pending = ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-flash', caller.signal) + await started.promise + caller.abort(new Error('caller left')) + await expect(pending).rejects.toThrow('caller left') +}) + it.each([[{}], [{ models: [{ id: 'custom' }] }]])('hides the route without a key and requests nothing for %j', async (config) => { const requests = officialListing(listing(FLASH)) const { ctx } = await host('') diff --git a/packages/llm/llm-deepseek/tests/official-route-turn.spec.ts b/packages/llm/llm-deepseek/tests/official-route-turn.spec.ts new file mode 100644 index 0000000000..054a44ec78 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/official-route-turn.spec.ts @@ -0,0 +1,42 @@ +/** Turns on the official endpoint report credential failures on the model request, after the prompt is recorded. */ +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as DeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' + +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +it('records the prompt and fails the request when the official endpoint has a malformed key', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'invalid\nheader') + const fetch = vi.spyOn(globalThis, 'fetch') + const ctx = new Context() + contexts.push(ctx) + for (const plugin of [ + LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, NativeToolExecutor, AgentRegistry, + ]) { + await ctx.plugin(plugin) + } + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(DeepSeek, {}) + const agent = await ctx.agentLoop.create(SessionId('malformed-official-key'), { provider: 'deepseek-official', model: 'deepseek-flash' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } })) + await agent.whenIdle() + const events = agent.session.snapshotEvents() + expect(events.map(event => event.type)).toContain('user/message') + expect(events.find(event => event.type === 'assistant/attempt')?.data).toMatchObject({ + stream: [{ chunk: { type: 'finish', reason: { kind: 'error', failure: { code: 'INVALID_CREDENTIAL' } } } }], + }) + expect(fetch).not.toHaveBeenCalled() +})