Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a3fb762
feat(api): add abort-signal cancellation-scope helpers to the shared …
easonliang28 Sep 16, 2026
8d8ee07
feat(api): abort signal support for opencode-go
easonliang28 Sep 16, 2026
044e22b
feat(api): abort signal support for unbound, vercel-ai-gateway, zoo-g…
easonliang28 Sep 16, 2026
8fd0325
Merge upstream main (3859e5dd8) into feat/abort-r1-u3-gateway-siblings
Oct 4, 2026
07aeb6d
fix(providers): honour the caller's cancellation scope in completePrompt
Oct 4, 2026
8043f3e
fix(providers): detach the abort bridge when request preparation throws
Oct 7, 2026
1b9814a
chore: trigger a fresh review pass at this head
Oct 7, 2026
cd8edc3
Merge upstream main (285f6687a) into feat/abort-r1-u3-gateway-siblings
easonliang28 Oct 13, 2026
470e9cf
Merge upstream/main (4f1dee18e) into feat/abort-r1-u3-gateway-siblings
easonliang28 Oct 16, 2026
5c4c598
test(providers): rename unused mock options params in gateway specs
easonliang28 Oct 16, 2026
fe3dfe5
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-u3-ga…
easonLiangWorldedtech Oct 26, 2026
76f0dc8
fix(providers): adopt shared timeout helpers and drop Stryker suppres…
easonLiangWorldedtech Oct 26, 2026
ed73cd8
refactor(providers): share the external abort bridge in the gateway p…
easonLiangWorldedtech Oct 28, 2026
17975dd
Merge remote-tracking branch 'upstream/main' into feat/abort-r1-u3-ga…
easonLiangWorldedtech Oct 28, 2026
09aa5a0
test(abort): cover listener detachment on throw, rejection and early …
easonLiangWorldedtech Nov 2, 2026
7f78d17
test(opencode-go): use the shared clearAllMocks helper
easonLiangWorldedtech Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,170 changes: 1,103 additions & 67 deletions src/api/providers/__tests__/opencode-go.spec.ts

Large diffs are not rendered by default.

837 changes: 833 additions & 4 deletions src/api/providers/__tests__/unbound.spec.ts

Large diffs are not rendered by default.

508 changes: 506 additions & 2 deletions src/api/providers/__tests__/vercel-ai-gateway.spec.ts

Large diffs are not rendered by default.

462 changes: 461 additions & 1 deletion src/api/providers/__tests__/zoo-gateway.spec.ts

Large diffs are not rendered by default.

347 changes: 262 additions & 85 deletions src/api/providers/opencode-go.ts

Large diffs are not rendered by default.

145 changes: 109 additions & 36 deletions src/api/providers/unbound.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import OpenAI, { APIConnectionTimeoutError } from "openai"

import {
type ModelInfo,
Expand Down Expand Up @@ -29,6 +29,14 @@ import type {
} from "../index"
import { handleOpenAIError } from "./utils/error-handler"
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
import {
bridgeExternalAbortSignal,
createAbortError,
hasUsableTimeout,
isRequestAborted,
resolveModelWithAbort,
toSdkTimeoutMs,
} from "./utils/abort-signal"
import { extractReasoningFromDelta } from "./utils/extract-reasoning"

// Unbound usage includes extra fields for Anthropic cache tokens.
Expand Down Expand Up @@ -143,14 +151,21 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
// Establish the cancellation scope around model resolution: a
// pre-aborted signal rejects before the lookup starts, and a signal
// that fires while model metadata is loading settles on the
// standardized AbortError; any other resolution failure propagates
// unchanged.
const externalAbortSignal = metadata?.abortSignal
const resolved = await resolveModelWithAbort(() => this.fetchModel(), externalAbortSignal, "Unbound")
const {
id: model,
info,
maxTokens: max_tokens,
temperature,
reasoningEffort: reasoning_effort,
reasoning: thinking,
} = await this.fetchModel()
} = resolved

const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
Expand All @@ -176,51 +191,86 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand
tool_choice: metadata?.tool_choice,
}

let stream
// Per-request signal bridged from the caller's abort signal via the shared helper
// (pre-aborted guard, once-only listener, retained cleanup). The never-aborting
// fallback preserves the previous always-signal behaviour when the caller has no
// external signal, and dispose() detaches the listener when the request ends.
const { signal: bridgedSignal, dispose: detachExternalAbortSignal } =
bridgeExternalAbortSignal(externalAbortSignal)
const requestSignal = bridgedSignal ?? new AbortController().signal

try {
stream = await this.client.chat.completions.create(completionParams)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
let lastUsage: any = undefined
let stream
try {
stream = await this.client.chat.completions.create(completionParams, { signal: requestSignal })
} catch (error) {
// Preserve abort identity (series standard): a cancelled request
// must surface as a DOM-standard AbortError, not a wrapped
// completion error.
if (isRequestAborted(error, externalAbortSignal)) {
throw createAbortError("Unbound")
}
throw handleOpenAIError(error, this.providerName)
}
let lastUsage: any = undefined

for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
try {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta

const reasoningText = extractReasoningFromDelta(delta)
if (reasoningText) {
yield { type: "reasoning", text: reasoningText }
}
const reasoningText = extractReasoningFromDelta(delta)
if (reasoningText) {
yield { type: "reasoning", text: reasoningText }
}

if (delta?.content) {
yield { type: "text", text: delta.content }
}
if (delta?.content) {
yield { type: "text", text: delta.content }
}

// Handle native tool calls
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
for (const toolCall of delta.tool_calls) {
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
}

// Handle native tool calls
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
for (const toolCall of delta.tool_calls) {
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
if (chunk.usage) {
lastUsage = chunk.usage
}
}
}

if (chunk.usage) {
lastUsage = chunk.usage
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, info)
}
} catch (error) {
// Preserve abort identity (series standard): a cancellation that
// surfaces after the stream has started must also normalize to
// the standardized AbortError, not the raw SDK rejection.
if (isRequestAborted(error, externalAbortSignal)) {
throw createAbortError("Unbound")
}
throw error
}
}

if (lastUsage) {
yield this.processUsageMetrics(lastUsage, info)
} finally {
detachExternalAbortSignal()
}
}

async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise<string> {
const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel()
async completePrompt(prompt: string, options?: CompletePromptOptions): Promise<string> {
// Model resolution must honour the caller's cancellation scope the same
// way createMessage does: a signal that fires during the lookup settles on
// the standardized AbortError instead of waiting for the catalog to finish.
const {
id: model,
maxTokens: max_tokens,
temperature,
} = await resolveModelWithAbort(() => this.fetchModel(), options?.abortSignal, "Unbound")

const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }]

Expand All @@ -230,11 +280,34 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand
messages: openAiMessages,
temperature: temperature,
}
// Build request options with abortSignal and/or timeout.
// timeoutMs <= 0 means "no explicit timeout": omit the SDK timeout
// option entirely — the OpenAI SDK treats timeout: 0 as an immediate
// abort, which would cancel the request right away.
const createOptions: OpenAI.RequestOptions = {}
if (options?.abortSignal) {
createOptions.signal = options.abortSignal
}
const timeoutMs = options?.timeoutMs
if (hasUsableTimeout(timeoutMs)) {
createOptions.timeout = toSdkTimeoutMs(timeoutMs)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

let response: OpenAI.Chat.ChatCompletion
try {
response = await this.client.chat.completions.create(completionParams)
response = await this.client.chat.completions.create(completionParams, createOptions)
} catch (error) {
// Preserve abort identity (series standard): caller-initiated
// cancellations and request timeouts must surface as a
// DOM-standard AbortError, not a wrapped completion error. The
// OpenAI SDK reports both with messages ending in a period
// ("Request was aborted.", "Request timed out."), which would not
// match task-level abort detection (message ending in "aborted").
// SDK request timeouts are not aborts, but the series standard maps
// them to the same AbortError identity as caller cancellations.
if (isRequestAborted(error, options?.abortSignal) || error instanceof APIConnectionTimeoutError) {
throw createAbortError("Unbound")
}
throw handleOpenAIError(error, this.providerName)
}
return response.choices[0]?.message.content || ""
Expand Down
Loading
Loading