Skip to content
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const historyItemSchema = z.object({
ts: z.number(),
task: z.string(),
title: z.string().optional(), // User-assigned display name; `task` keeps the original prompt
titleCleared: z.boolean().optional(), // Persisted manual clear: blocks an automatically generated title
tokensIn: z.number(),
tokensOut: z.number(),
cacheWrites: z.number().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,26 @@ describe("BaseOpenAiCompatibleProvider", () => {
expect(mockCreate.mock.calls[0][0].max_tokens).toBeUndefined()
})

it("forwards the abort signal and deadline to the completion request", async () => {
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "ok" } }] })
const { signal } = new AbortController()

await new TestOpenAiCompatibleProvider("test-api-key", {}).completePrompt("hello", {
abortSignal: signal,
timeoutMs: 5_000,
})

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything(), { signal, timeout: 5_000 })
})

it("sends a single-argument request when no cancellation or deadline is given", async () => {
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "ok" } }] })

await new TestOpenAiCompatibleProvider("test-api-key", {}).completePrompt("hello", { timeoutMs: 0 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything())
})

it("completePrompt sends the custom model id verbatim to the API", async () => {
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "ok" } }] })

Expand Down
19 changes: 19 additions & 0 deletions src/api/providers/__tests__/kenari.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,25 @@ describe("KenariHandler", () => {
await expect(handler.completePrompt("ping")).rejects.toThrow("Kenari completion error: boom")
})

it("forwards the abort signal and deadline to the completion request", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
const handler = new KenariHandler(mockOptions)
const { signal } = new AbortController()

await handler.completePrompt("ping", { abortSignal: signal, timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything(), { signal, timeout: 5_000 })
})

it("sends a single-argument request when no cancellation or deadline is given", async () => {
mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
const handler = new KenariHandler(mockOptions)

await handler.completePrompt("ping", { timeoutMs: Number.NaN })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything())
})

it("re-throws non-Error rejections unchanged", async () => {
mockCreate.mockRejectedValue("string failure")
const handler = new KenariHandler(mockOptions)
Expand Down
50 changes: 50 additions & 0 deletions src/api/providers/__tests__/kimi-code.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,56 @@ describe("KimiCodeHandler", () => {
expect(createCompletion).toHaveBeenCalledTimes(2)
})

it("forwards the caller's cancellation and deadline to each completion attempt", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
const unauthorized = Object.assign(new Error("Unauthorized"), { status: 401 })
const client = (
handler as unknown as { client: { chat: { completions: { create: ReturnType<typeof vi.fn> } } } }
).client
const createCompletion = vi
.spyOn(client.chat.completions, "create")
.mockRejectedValueOnce(unauthorized)
.mockResolvedValueOnce({ choices: [{ message: { content: "retried" } }] })
const { signal } = new AbortController()

await expect(handler.completePrompt("test", { abortSignal: signal, timeoutMs: 5_000 })).resolves.toBe("retried")

// Both the initial attempt and the retry after the forced refresh carry the caller's own signal.
expect(createCompletion.mock.calls[0][1].signal).toBe(signal)
expect(createCompletion.mock.calls[0][1].timeout).toBe(5_000)
expect(createCompletion.mock.calls[1][1].signal).toBe(signal)
expect(createCompletion.mock.calls[1][1].timeout).toBe(5_000)
})

it("does not start preparation when the caller already aborted", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
const controller = new AbortController()
controller.abort()

await expect(handler.completePrompt("test", { abortSignal: controller.signal })).rejects.toThrow()
expect(mockGetAccessToken).not.toHaveBeenCalled()
})

it("stops before model discovery and completion when aborted during token resolution", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "oauth" })
let resolveToken!: (token: string) => void
mockGetAccessToken.mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveToken = resolve
}),
)
const controller = new AbortController()

const completion = handler.completePrompt("test", { abortSignal: controller.signal })
await vi.waitFor(() => expect(resolveToken).toBeDefined())
controller.abort()
resolveToken("oauth-token")

await expect(completion).rejects.toMatchObject({ name: "AbortError" })
// The cancelled request never reaches model discovery or the provider.
expect(mockGetModels).not.toHaveBeenCalled()
})

it("does not retry on 401 when using API key auth", async () => {
const handler = new KimiCodeHandler({ kimiCodeAuthMethod: "api-key", kimiCodeApiKey: "key" })
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response(null, { status: 401 }))
Expand Down
19 changes: 19 additions & 0 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,25 @@ describe("OpenAiHandler", () => {
})

describe("completePrompt", () => {
it("forwards the abort signal and deadline to the completion request", async () => {
const { signal } = new AbortController()

await handler.completePrompt("Test prompt", { abortSignal: signal, timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything(), { signal, timeout: 5_000 })
})

it("does not start a request when the caller already aborted", async () => {
const controller = new AbortController()
controller.abort()
mockCreate.mockClear()

await expect(handler.completePrompt("Test prompt", { abortSignal: controller.signal })).rejects.toThrow(
"This operation was aborted",
)
expect(mockCreate).not.toHaveBeenCalled()
})

it("should complete prompt successfully", async () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
Expand Down
83 changes: 83 additions & 0 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,90 @@ describe("OpenRouterHandler", () => {
})
})

// Installs a typed completion double without widening the client to `any`.
const installMockClient = (create: ReturnType<typeof vitest.fn>) => {
;(OpenAI as unknown as { prototype: { chat: unknown } }).prototype.chat = {
completions: { create },
}
}

describe("completePrompt", () => {
it("forwards the abort signal and deadline to the completion request", async () => {
const handler = new OpenRouterHandler(mockOptions)
const { signal } = new AbortController()
const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
installMockClient(mockCreate)

await handler.completePrompt("test prompt", { abortSignal: signal, timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: mockOptions.openRouterModelId }), {
headers: { "x-anthropic-beta": "fine-grained-tool-streaming-2025-05-14" },
signal,
timeout: 5_000,
})
})

it("sends no request options when the caller passes none", async () => {
const handler = new OpenRouterHandler(
makeApiHandlerOptions({
openRouterApiKey: "test-key",
openRouterModelId: "openai/gpt-4o",
}),
)
const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
installMockClient(mockCreate)

await handler.completePrompt("test prompt")

expect(mockCreate).toHaveBeenLastCalledWith(expect.objectContaining({ model: "openai/gpt-4o" }), undefined)
})

it("forwards only the abort signal when no deadline is given", async () => {
const handler = new OpenRouterHandler(
makeApiHandlerOptions({
openRouterApiKey: "test-key",
openRouterModelId: "openai/gpt-4o",
}),
)
const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
installMockClient(mockCreate)
const { signal } = new AbortController()

await handler.completePrompt("test prompt", { abortSignal: signal })

expect(mockCreate).toHaveBeenLastCalledWith(expect.objectContaining({ model: "openai/gpt-4o" }), { signal })
})

it("forwards only the deadline when no signal is given", async () => {
const handler = new OpenRouterHandler(
makeApiHandlerOptions({
openRouterApiKey: "test-key",
openRouterModelId: "openai/gpt-4o",
}),
)
const mockCreate = vitest.fn().mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
installMockClient(mockCreate)

await handler.completePrompt("test prompt", { timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.objectContaining({ model: "openai/gpt-4o" }), {
timeout: 5_000,
})
})

it("does not start a request when the caller already aborted", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockCreate = vitest.fn()
installMockClient(mockCreate)
const controller = new AbortController()
controller.abort()

await expect(
handler.completePrompt("test prompt", { abortSignal: controller.signal }),
).rejects.toMatchObject({ name: "AbortError" })
expect(mockCreate).not.toHaveBeenCalled()
})

it("returns correct response", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockResponse = { choices: [{ message: { content: "test completion" } }] }
Expand Down
60 changes: 60 additions & 0 deletions src/api/providers/__tests__/poe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,67 @@ describe("PoeHandler", () => {
})
})

// Holds generateText pending so a test can observe the request signal while it is in
// flight; `release` settles the call.
function holdGeneration(generateTextMock: {
mockImplementation: (fn: (options: { abortSignal?: AbortSignal }) => Promise<{ text: string }>) => unknown
}) {
let captured: { abortSignal?: AbortSignal } | undefined
let release: () => void = () => {}
generateTextMock.mockImplementation(
(options) =>
new Promise<{ text: string }>((resolve) => {
captured = options
release = () => resolve({ text: "generated response" })
}),
)
return { release: () => release(), abortSignal: () => captured?.abortSignal }
}

describe("completePrompt", () => {
it("links the caller's stop into the in-flight generation signal", async () => {
const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" })
const { release, abortSignal } = holdGeneration(mockGenerateText)
const abortController = new AbortController()

const generation = handler.completePrompt("complete this", {
abortSignal: abortController.signal,
timeoutMs: 5_000,
})
await vi.waitFor(() => expect(abortSignal()).toBeDefined())
// A per-request signal carries the caller's stop while the request is still running.
expect(abortSignal()?.aborted).toBe(false)

abortController.abort()
expect(abortSignal()?.aborted).toBe(true)
// The generation only settles when the provider call is released.
release()
await expect(generation).resolves.toBe("generated response")
})

it("aborts the in-flight generation signal when the deadline passes", async () => {
const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" })
const { release, abortSignal } = holdGeneration(mockGenerateText)

const generation = handler.completePrompt("complete this", { timeoutMs: 200 })
await vi.waitFor(() => expect(abortSignal()).toBeDefined())

// The deadline aborts the request while the provider call is still pending.
await vi.waitFor(() => expect(abortSignal()?.aborted).toBe(true))
release()
await expect(generation).resolves.toBe("generated response")
})

it("does not start a generation when the caller already aborted", async () => {
const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" })
mockGenerateText.mockClear()
const controller = new AbortController()
controller.abort()

await expect(handler.completePrompt("complete this", { abortSignal: controller.signal })).rejects.toThrow()
expect(mockGenerateText).not.toHaveBeenCalled()
})

it("returns generated text", async () => {
const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" })

Expand Down
19 changes: 19 additions & 0 deletions src/api/providers/__tests__/requesty.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,25 @@ describe("RequestyHandler", () => {
})
})

it("forwards the abort signal and deadline to the completion request", async () => {
const handler = new RequestyHandler(mockOptions)
mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
const { signal } = new AbortController()

await handler.completePrompt("test prompt", { abortSignal: signal, timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything(), { signal, timeout: 5_000 })
})

it("sends a single-argument request when no cancellation or deadline is given", async () => {
const handler = new RequestyHandler(mockOptions)
mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })

await handler.completePrompt("test prompt", { timeoutMs: 0 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything())
})

it("omits temperature for Claude Fable 5 in completePrompt", async () => {
const handler = new RequestyHandler(
makeApiHandlerOptions({
Expand Down
23 changes: 23 additions & 0 deletions src/api/providers/__tests__/unbound.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,29 @@ describe("UnboundHandler", () => {
)
})

type OpenAiClientStub = { chat: { completions: { create: ReturnType<typeof vi.fn> } } }

it("forwards the abort signal and deadline to the completion request", async () => {
const mockCreate = (OpenAI as unknown as () => OpenAiClientStub)().chat.completions.create
mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
const handler = new UnboundHandler({ unboundApiKey: "test-key", unboundModelId: "openai/gpt-4o" })
const { signal } = new AbortController()

await handler.completePrompt("Write a haiku", { abortSignal: signal, timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything(), { signal, timeout: 5_000 })
})

it("sends a single-argument request when no cancellation or deadline is given", async () => {
const mockCreate = (OpenAI as unknown as () => OpenAiClientStub)().chat.completions.create
mockCreate.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
const handler = new UnboundHandler({ unboundApiKey: "test-key", unboundModelId: "openai/gpt-4o" })

await handler.completePrompt("Write a haiku", { timeoutMs: -1 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything())
})

describe("model catalog", () => {
it("reports its cache scope", () => {
const handler = new UnboundHandler({ unboundApiKey: "test-key", unboundModelId: "openai/gpt-4o" })
Expand Down
17 changes: 17 additions & 0 deletions src/api/providers/__tests__/vercel-ai-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,23 @@ describe("VercelAiGatewayHandler", () => {
}))
})

it("forwards the abort signal and deadline to the completion request", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const { signal } = new AbortController()

await handler.completePrompt("Complete this: Hello", { abortSignal: signal, timeoutMs: 5_000 })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything(), { signal, timeout: 5_000 })
})

it("sends a single-argument request when no cancellation or deadline is given", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)

await handler.completePrompt("Complete this: Hello", { timeoutMs: Number.POSITIVE_INFINITY })

expect(mockCreate).toHaveBeenLastCalledWith(expect.anything())
})

it("completes prompt correctly", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const prompt = "Complete this: Hello"
Expand Down
Loading
Loading