diff --git a/src/api/providers/__tests__/poe.spec.ts b/src/api/providers/__tests__/poe.spec.ts index 00712924f5..7d8bc9a5a2 100644 --- a/src/api/providers/__tests__/poe.spec.ts +++ b/src/api/providers/__tests__/poe.spec.ts @@ -3,7 +3,9 @@ import { poeDefaultModelId, providerIdentifiers } from "@roo-code/types" import { PoeHandler } from "../poe" import { getModelsFromCache } from "../fetchers/modelCache" +import { makeCreateMessageMetadata } from "../../../test-utils/api" import { clearAllMocks } from "../../../test-utils/reset" +import { collectStream } from "../../../test-utils/stream" const { mockStreamText, mockGenerateText, mockCreatePoe, mockGetModelsFromCache, mockCaptureException } = vitest.hoisted(() => ({ @@ -237,6 +239,334 @@ describe("PoeHandler", () => { }), ) }) + + it("rejects with AbortError when the external signal is pre-aborted", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockStreamText.mockReturnValue({ + fullStream: (async function* () {})(), + usage: Promise.resolve(undefined), + }) + + const controller = new AbortController() + controller.abort() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + await expect( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }], metadata).next(), + ).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + // Fail fast before any model lookup: no API work happens once the signal is aborted. + expect(mockGetModelsFromCache).not.toHaveBeenCalled() + }) + + it("aborts the in-flight stream and rejects with AbortError when the external signal aborts", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + + let requestSignal: AbortSignal | undefined + mockStreamText.mockImplementationOnce((args: { abortSignal?: AbortSignal }) => { + requestSignal = args.abortSignal + // Emulate the AI SDK: the first chunk arrives, then the stream errors once + // the abort signal fires. + const fullStream = (async function* () { + yield { type: "text-delta", text: "Hello " } + await new Promise((resolve) => { + if (requestSignal?.aborted) { + resolve() + } else { + requestSignal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The operation was aborted") + abortError.name = "AbortError" + throw abortError + })() + return { fullStream, usage: Promise.resolve(undefined) } + }) + + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + + const chunks: unknown[] = [] + const iteration = (async () => { + for await (const chunk of handler.createMessage( + "system", + [{ role: "user" as const, content: "hi" }], + metadata, + )) { + chunks.push(chunk) + if (chunk.type === "text") { + // Abort while the stream is still in flight. + controller.abort() + } + } + })() + + await expect(iteration).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + expect(chunks).toContainEqual({ type: "text", text: "Hello " }) + }) + + it("stops yielding chunks once the external signal aborts mid-stream", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + // The upstream stream ignores the abort and keeps producing late chunks; the + // provider must stop yielding them once the signal is aborted. + const fullStream = (async function* () { + yield { type: "text-delta", text: "Hello " } + yield { type: "text-delta", text: "late" } + })() + mockStreamText.mockReturnValueOnce({ + fullStream, + usage: Promise.resolve(undefined), + }) + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const chunks: unknown[] = [] + const iteration = (async () => { + for await (const chunk of handler.createMessage( + "system", + [{ role: "user" as const, content: "hi" }], + metadata, + )) { + chunks.push(chunk) + if (chunk.type === "text") { + // Abort while the stream is still in flight. + controller.abort() + } + } + })() + + await expect(iteration).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + // No late chunk may reach the caller after the abort. + expect(chunks).toEqual([{ type: "text", text: "Hello " }]) + }) + + it("rejects with AbortError when the external signal aborts after the stream ends", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + + // The stream completes normally; the signal aborts while the provider is about + // to await result.usage. The abort must reject the stream before the usage + // promise is even read. + let usageAccessed = false + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () { + yield { type: "text-delta", text: "done" } + })(), + get usage() { + usageAccessed = true + return Promise.resolve({ inputTokens: 1, outputTokens: 1 }) + }, + }) + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const iteration = (async () => { + for await (const chunk of handler.createMessage( + "system", + [{ role: "user" as const, content: "hi" }], + metadata, + )) { + if (chunk.type === "text") { + controller.abort() + } + } + })() + + await expect(iteration).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + expect(usageAccessed).toBe(false) + }) + + it("rejects with AbortError when the external signal aborts while the usage promise is pending", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + + // The stream completes without aborting, so the provider reaches + // await result.usage. The signal aborts in the microtask between reading + // the usage promise and the usage settling: only the post-usage checkpoint + // can turn that late usage into an AbortError. + let reachedUsageAwait: (() => void) | undefined + let resolveUsage: (usage: { inputTokens: number; outputTokens: number }) => void = () => {} + const usagePromise = new Promise<{ inputTokens: number; outputTokens: number }>((resolve) => { + resolveUsage = resolve + }) + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () { + yield { type: "text-delta", text: "x" } + })(), + get usage() { + queueMicrotask(() => reachedUsageAwait?.()) + return usagePromise + }, + }) + const reachedUsage = new Promise((resolve) => { + reachedUsageAwait = resolve + }) + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const promise = collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }], metadata), + ) + await reachedUsage + controller.abort() + resolveUsage({ inputTokens: 2, outputTokens: 2 }) + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + }) + + it("bridges the external abort signal into the request controller synchronously", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + + let requestSignal: AbortSignal | undefined + mockStreamText.mockImplementationOnce((args: { abortSignal?: AbortSignal }) => { + requestSignal = args.abortSignal + const fullStream = (async function* () { + yield { type: "text-delta", text: "x" } + // Hold the stream open until the request controller is aborted. + await new Promise((resolve) => { + if (requestSignal?.aborted) { + resolve() + } else { + requestSignal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + throw new Error("aborted") + })() + return { fullStream, usage: Promise.resolve(undefined) } + }) + + const controller = new AbortController() + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const generator = handler.createMessage("system", [{ role: "user" as const, content: "hi" }], metadata) + + const first = await generator.next() + expect(first.value).toEqual({ type: "text", text: "x" }) + expect(requestSignal?.aborted).toBe(false) + + controller.abort() + + // The bridge listener aborts the request controller synchronously, so the + // in-flight request is cancelled even before the stream observes it. + expect(requestSignal?.aborted).toBe(true) + await expect(generator.next()).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + }) + + it("removes the external abort listener when the request completes without aborting", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + const addSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeSpy = vitest.spyOn(controller.signal, "removeEventListener") + + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () { + yield { type: "text-delta", text: "done" } + })(), + usage: Promise.resolve(undefined), + }) + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }], metadata), + ) + + expect(chunks).toContainEqual({ type: "text", text: "done" }) + // The finally block must remove the exact listener that was registered, not + // just any function: removing a different reference would leave the original + // abort listener attached to the signal. + const registeredListener = addSpy.mock.calls[0]?.[1] as EventListener | undefined + expect(removeSpy).toHaveBeenCalledWith("abort", registeredListener) + addSpy.mockRestore() + removeSpy.mockRestore() + }) + it("rejects with AbortError when the external signal aborts during request creation", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + + mockStreamText.mockImplementationOnce(() => { + // Emulate the AI SDK failing synchronously: abort the external signal first so + // the catch normalizes the failure to a DOM-standard AbortError. + controller.abort() + const abortError = new Error("The operation was aborted") + abortError.name = "AbortError" + throw abortError + }) + + const metadata = makeCreateMessageMetadata({ abortSignal: controller.signal }) + const nextPromise = handler + .createMessage("system", [{ role: "user" as const, content: "hi" }], metadata) + .next() + + await expect(nextPromise).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + }) + + it("rejects with a completion error when request creation fails without abort", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockStreamText.mockImplementationOnce(() => { + throw new Error("boom") + }) + + await expect( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]).next(), + ).rejects.toThrow("Poe completion error: boom") + }) + + it("rejects with a streaming error when the stream fails without abort", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () { + yield { type: "text-delta", text: "Hello " } + throw new Error("stream broke") + })(), + usage: Promise.resolve(undefined), + }) + + await expect( + collectStream(handler.createMessage("system", [{ role: "user" as const, content: "hi" }])), + ).rejects.toThrow("Poe streaming error: stream broke") + }) + + it("passes an explicitly configured temperature to streamText", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o", modelTemperature: 0.7 }) + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () {})(), + usage: Promise.resolve(undefined), + }) + + await collectStream(handler.createMessage("system", [{ role: "user" as const, content: "hi" }])) + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.temperature).toBe(0.7) + }) + + it("yields no usage chunk when the stream reports no usage", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () {})(), + usage: Promise.resolve(undefined), + }) + + const chunks = await collectStream( + handler.createMessage("system", [{ role: "user" as const, content: "hi" }]), + ) + expect(chunks).toEqual([]) + }) }) describe("reasoning", () => { @@ -398,5 +728,203 @@ describe("PoeHandler", () => { }), ) }) + + it("completePrompt should pass abort signal through to generateText", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + mockGenerateText.mockResolvedValueOnce({ text: "response" }) + + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: mockLanguageModel, + prompt: "test prompt", + abortSignal: controller.signal, + }), + ) + }) + + it("completePrompt should work without options (backward compatible)", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockGenerateText.mockResolvedValueOnce({ text: "response" }) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: mockLanguageModel, + prompt: "test prompt", + }), + ) + // Without options there is no merged signal: the call must not carry an abortSignal key. + expect(mockGenerateText.mock.calls[0][0]).not.toHaveProperty("abortSignal") + }) + + it("completePrompt should merge the abort signal and timeoutMs into a combined abortSignal", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + mockGenerateText.mockResolvedValueOnce({ text: "response" }) + + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: mockLanguageModel, + prompt: "test prompt", + abortSignal: expect.any(AbortSignal), + }), + ) + // The abortSignal should be a merged signal (not the original controller.signal). + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.abortSignal).toBeDefined() + expect(callArgs.abortSignal).toBeInstanceOf(AbortSignal) + expect(callArgs.abortSignal).not.toBe(controller.signal) + }) + + it("completePrompt rejects with AbortError when the per-request timeout expires before the response", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + // Emulate a slow response: the generation settles only once the merged abort + // signal (which embeds the per-request timeout) fires. + mockGenerateText.mockImplementationOnce(async (args: { abortSignal?: AbortSignal }) => { + await new Promise((resolve) => { + if (args.abortSignal?.aborted) { + resolve() + } else { + args.abortSignal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + return { text: "late response" } + }) + + const promise = handler.completePrompt("test prompt", { timeoutMs: 30 }) + const callArgs = mockGenerateText.mock.calls[0][0] as { abortSignal?: AbortSignal } + // The per-request timeout is embedded in the abort signal passed to the SDK... + expect(callArgs.abortSignal).toBeInstanceOf(AbortSignal) + + // ...and when it fires before the response settles, the provider rejects with + // the canonical AbortError instead of returning the late result. + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + }) + + it("completePrompt rejects with AbortError when the external signal aborts mid-flight", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + // Emulate the AI SDK: the in-flight generation rejects when the abort signal fires. + mockGenerateText.mockImplementationOnce(async (args: { abortSignal?: AbortSignal }) => { + await new Promise((resolve) => { + if (args.abortSignal?.aborted) { + resolve() + } else { + args.abortSignal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + const abortError = new Error("The operation was aborted") + abortError.name = "AbortError" + throw abortError + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs.abortSignal).toBeDefined() + + // Abort the external signal before the generation settles. + controller.abort() + + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + // The merged signal should be aborted once the user signal aborts. + expect(callArgs.abortSignal.aborted).toBe(true) + }) + + it("completePrompt should handle timeoutMs=0 as no timeout", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockGenerateText.mockResolvedValueOnce({ text: "response" }) + + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + model: mockLanguageModel, + prompt: "test prompt", + }), + ) + const callArgs = mockGenerateText.mock.calls[0][0] + expect(callArgs).not.toHaveProperty("abortSignal") + }) + + it("completePrompt should handle non-Error values in catch block", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + mockGenerateText.mockRejectedValueOnce("not an error") + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("Poe completion error: not an error") + }) + it("completePrompt rejects with AbortError when the response resolves after abort", async () => { + const handler = new PoeHandler({ poeApiKey: "key", apiModelId: "openai/gpt-4o" }) + const controller = new AbortController() + mockGenerateText.mockImplementationOnce(async (args: { abortSignal?: AbortSignal }) => { + // The generation only settles once the abort signal has fired (late result). + await new Promise((resolve) => { + if (args.abortSignal?.aborted) { + resolve() + } else { + args.abortSignal?.addEventListener("abort", () => resolve(), { once: true }) + } + }) + return { text: "late result" } + }) + + const promise = handler.completePrompt("test prompt", { abortSignal: controller.signal }) + controller.abort() + + await expect(promise).rejects.toMatchObject({ + name: "AbortError", + message: "The Poe request was aborted", + }) + }) + + it("passes reasoning effort to streamText via createMessage", async () => { + const handler = new PoeHandler({ + poeApiKey: "key", + apiModelId: "openai/o3", + enableReasoningEffort: true, + reasoningEffort: "low", + modelMaxTokens: 8192, + }) + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () {})(), + usage: Promise.resolve(undefined), + }) + + await handler.createMessage("system", [{ role: "user" as const, content: "hi" }]).next() + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + maxOutputTokens: 8192, + providerOptions: { poe: { reasoningEffort: "low", reasoningSummary: "auto" } }, + }), + ) + }) + + it("does not set maxOutputTokens when modelMaxTokens is falsy on the effort path", async () => { + const handler = new PoeHandler({ + poeApiKey: "key", + apiModelId: "openai/o3", + enableReasoningEffort: true, + reasoningEffort: "low", + modelMaxTokens: 0, + }) + mockStreamText.mockReturnValueOnce({ + fullStream: (async function* () {})(), + usage: Promise.resolve(undefined), + }) + + await handler.createMessage("system", [{ role: "user" as const, content: "hi" }]).next() + + // 0 is falsy: the guard must skip the override, so the SDK receives the default (undefined). + expect(mockStreamText.mock.calls[0][0].maxOutputTokens).toBeUndefined() + }) }) }) diff --git a/src/api/providers/poe.ts b/src/api/providers/poe.ts index fb3255c572..491d2005e0 100644 --- a/src/api/providers/poe.ts +++ b/src/api/providers/poe.ts @@ -22,6 +22,7 @@ import { BaseProvider } from "./base-provider" import { NOT_PROVIDED } from "./constants" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index" import { getModelsFromCache } from "./fetchers/modelCache" +import { createAbortError, mergeAbortSignalAndTimeout } from "./utils/abort-signal" const DEFAULT_THINKING_BUDGET = 8192 @@ -54,105 +55,179 @@ export class PoeHandler extends BaseProvider implements SingleCompletionHandler messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const { id, info } = this.getModel() - const languageModel = this.poe(id) - - const aiSdkMessages = convertToAiSdkMessages(messages) - const openAiTools = this.convertToolsForOpenAI(metadata?.tools) - const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - - const useBudget = shouldUseReasoningBudget({ model: info, settings: this.options }) - const useEffort = !useBudget && shouldUseReasoningEffort({ model: info, settings: this.options }) - - // Only pass temperature when the user explicitly configured it. - let temperature: number | undefined = this.options.modelTemperature ?? undefined - let maxOutputTokens: number | undefined - const providerOptions: NonNullable[0]["providerOptions"]> & { - poe?: PoeScopedProviderOptions - } = {} - - if (useBudget) { - const requestedBudget = this.options.modelMaxThinkingTokens ?? DEFAULT_THINKING_BUDGET - // maxOutputTokens is the text-only budget; reasoningBudgetTokens is - // separate, so total output = maxOutputTokens + reasoningBudgetTokens. - maxOutputTokens = this.options.modelMaxTokens ?? Math.max(0, (info.maxTokens ?? 0) - requestedBudget) - providerOptions.poe = { - reasoningBudgetTokens: requestedBudget, - } - temperature = 1.0 - } else if (useEffort) { - let effort = (this.options.reasoningEffort ?? info.reasoningEffort ?? "medium") as ReasoningEffortExtended - // Validate that the effort level is actually supported by the current model - const supportedEfforts = info.supportsReasoningEffort - if (Array.isArray(supportedEfforts) && !supportedEfforts.includes(effort as any)) { - effort = (info.reasoningEffort as ReasoningEffortExtended) ?? "medium" - } - providerOptions.poe = { - reasoningEffort: effort, - reasoningSummary: "auto", - } - if (this.options.modelMaxTokens) { - maxOutputTokens = this.options.modelMaxTokens + // Per-request AbortController: external aborts cancel the in-flight AI SDK request + // (the AI SDK aborts the underlying fetch when its abortSignal fires). + const controller = new AbortController() + + // Bridge the external abort signal into the per-request controller: + // - pre-aborted guard: abort immediately when the signal is already aborted + // - { once: true }: the listener removes itself after the first abort + // - explicit removal in finally: the listener must not outlive a request that + // completes (or fails) without being aborted + const externalAbortSignal = metadata?.abortSignal + let removeExternalAbortListener: (() => void) | undefined + if (externalAbortSignal) { + if (externalAbortSignal.aborted) { + controller.abort() + } else { + const onExternalAbort = () => controller.abort() + // Stryker disable next-line ObjectLiteral,BooleanLiteral: an AbortSignal fires its "abort" event at most once and the finally block removes this listener explicitly, so the once flag is unobservable + externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) + removeExternalAbortListener = () => externalAbortSignal.removeEventListener("abort", onExternalAbort) } } - let result try { - result = streamText({ - model: languageModel, - system: systemPrompt, - messages: aiSdkMessages, - temperature, - maxOutputTokens, - tools: aiSdkTools, - toolChoice: mapToolChoice(metadata?.tool_choice as any), - ...(Object.keys(providerOptions).length > 0 && { providerOptions }), - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException( - new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), - ) - throw new Error(`Poe completion error: ${errorMessage}`) - } + // The request was already aborted before we started: fail fast without calling the API. + if (controller.signal.aborted) { + throw createAbortError("Poe") + } - try { - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk + const { id, info } = this.getModel() + const languageModel = this.poe(id) + const aiSdkMessages = convertToAiSdkMessages(messages) + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const useBudget = shouldUseReasoningBudget({ model: info, settings: this.options }) + const useEffort = !useBudget && shouldUseReasoningEffort({ model: info, settings: this.options }) + + // Only pass temperature when the user explicitly configured it. + let temperature: number | undefined = this.options.modelTemperature ?? undefined + let maxOutputTokens: number | undefined + const providerOptions: NonNullable[0]["providerOptions"]> & { + poe?: PoeScopedProviderOptions + } = {} + + if (useBudget) { + const requestedBudget = this.options.modelMaxThinkingTokens ?? DEFAULT_THINKING_BUDGET + // maxOutputTokens is the text-only budget; reasoningBudgetTokens is + // separate, so total output = maxOutputTokens + reasoningBudgetTokens. + maxOutputTokens = this.options.modelMaxTokens ?? Math.max(0, (info.maxTokens ?? 0) - requestedBudget) + providerOptions.poe = { + reasoningBudgetTokens: requestedBudget, + } + temperature = 1.0 + } else if (useEffort) { + let effort = (this.options.reasoningEffort ?? + info.reasoningEffort ?? + "medium") as ReasoningEffortExtended + // Validate that the effort level is actually supported by the current model + const supportedEfforts = info.supportsReasoningEffort + // Stryker disable next-line ConditionalExpression,BlockStatement: shouldUseReasoningEffort already validated this effort against this capability array, and non-array capabilities fail the Array.isArray check, so this branch never executes + if (Array.isArray(supportedEfforts) && !supportedEfforts.includes(effort as any)) { + // Stryker disable next-line StringLiteral,LogicalOperator: the same shouldUseReasoningEffort gate guarantee makes this fallback unreachable + effort = (info.reasoningEffort as ReasoningEffortExtended) ?? "medium" + } + providerOptions.poe = { + reasoningEffort: effort, + reasoningSummary: "auto", + } + if (this.options.modelMaxTokens) { + maxOutputTokens = this.options.modelMaxTokens } } - const usage = await result.usage - if (usage) { - const metrics = extractUsageMetrics(usage as any) - yield { - type: "usage" as const, - inputTokens: metrics.inputTokens, - outputTokens: metrics.outputTokens, - cacheReadTokens: metrics.cacheReadTokens, - cacheWriteTokens: metrics.cacheWriteTokens, - reasoningTokens: metrics.reasoningTokens, + let result + try { + result = streamText({ + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature, + maxOutputTokens, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice as any), + ...(Object.keys(providerOptions).length > 0 && { providerOptions }), + abortSignal: controller.signal, + }) + } catch (error) { + // Aborted requests are user-initiated: surface them as AbortError instead of + // a completion error. + if (controller.signal.aborted) { + throw createAbortError("Poe") } + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), + ) + throw new Error(`Poe completion error: ${errorMessage}`) } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - TelemetryService.instance.captureException( - new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), - ) - throw new Error(`Poe streaming error: ${errorMessage}`) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + // Stop yielding once cancelled: a late chunk must not reach the caller. + if (controller.signal.aborted) { + // Stryker disable next-line StringLiteral: this error is only thrown while the signal is already aborted, and the catch below rethrows the canonical abort error, so the message built here is never observable + throw createAbortError("Poe") + } + yield chunk + } + } + + // Stop yielding once cancelled: do not await usage after the request was aborted. + if (controller.signal.aborted) { + // Stryker disable next-line StringLiteral: this error is only thrown while the signal is already aborted, and the catch below rethrows the canonical abort error, so the message built here is never observable + throw createAbortError("Poe") + } + const usage = await result.usage + // Usage may resolve while the request is already aborted: reject instead of yielding it. + if (controller.signal.aborted) { + // Stryker disable next-line StringLiteral: this error is only thrown while the signal is already aborted, and the catch below rethrows the canonical abort error, so the message built here is never observable + throw createAbortError("Poe") + } + if (usage) { + const metrics = extractUsageMetrics(usage as any) + yield { + type: "usage" as const, + inputTokens: metrics.inputTokens, + outputTokens: metrics.outputTokens, + cacheReadTokens: metrics.cacheReadTokens, + cacheWriteTokens: metrics.cacheWriteTokens, + reasoningTokens: metrics.reasoningTokens, + } + } + } catch (error) { + // Aborted requests are user-initiated: surface them as AbortError instead of + // a completion error. + if (controller.signal.aborted) { + throw createAbortError("Poe") + } + const errorMessage = error instanceof Error ? error.message : String(error) + TelemetryService.instance.captureException( + new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "createMessage"), + ) + throw new Error(`Poe streaming error: ${errorMessage}`) + } + } finally { + removeExternalAbortListener?.() } } async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { const { id } = this.getModel() + // Merge the caller's abort signal with the per-request timeout (timeoutMs <= 0 disables it). + const mergedAbortSignal = mergeAbortSignalAndTimeout(options?.abortSignal, options?.timeoutMs) try { const { text } = await generateText({ model: this.poe(id), prompt, + ...(mergedAbortSignal && { abortSignal: mergedAbortSignal }), }) + + if (mergedAbortSignal?.aborted) { + // The response resolved after the request was aborted: do not return the late result. + // Stryker disable next-line StringLiteral: this late-result error is always caught by the catch below, which rethrows the canonical abort error while the signal is still aborted, so the message built here is never observable + throw createAbortError("Poe") + } return text } catch (error) { + // Aborted requests are user-initiated: surface them as AbortError (this also covers + // timeouts, which abort the same signal) instead of a completion error. + if (mergedAbortSignal?.aborted) { + throw createAbortError("Poe") + } const errorMessage = error instanceof Error ? error.message : String(error) TelemetryService.instance.captureException( new ApiProviderError(errorMessage, providerIdentifiers.poe, id, "completePrompt"),