Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.

783 changes: 779 additions & 4 deletions src/api/providers/__tests__/unbound.spec.ts

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

352 changes: 269 additions & 83 deletions src/api/providers/opencode-go.ts

Large diffs are not rendered by default.

154 changes: 118 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,13 @@
} from "../index"
import { handleOpenAIError } from "./utils/error-handler"
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
import {
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 +150,21 @@
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 +190,96 @@
tool_choice: metadata?.tool_choice,
}

let stream
try {
stream = await this.client.chat.completions.create(completionParams)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
// Per-request controller so an external abort signal (e.g. task
// cancellation) can interrupt the in-flight streaming request.
// Bridge it to our controller using the Bedrock pattern:
// - pre-aborted guard: check if already aborted before adding listener
// - { once: true }: remove listener after first abort to avoid leaks
// The listener is stored so it can be detached when the request ends:
// { once: true } only removes it on abort, so a task-scoped signal
// would otherwise accumulate one listener per request.
const controller = new AbortController()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same inline abort bridge as zoo-gateway.ts:265. Could this use bridgeExternalAbortSignal and call dispose() in the finally?

const abortListener = () => controller.abort()
if (externalAbortSignal) {
if (externalAbortSignal.aborted) {

Check warning on line 204 in src/api/providers/unbound.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/unbound.ts:204: Survived ConditionalExpression mutant (replacement: false). See the job summary for the complete list and resolution guidance.
controller.abort()

Check warning on line 205 in src/api/providers/unbound.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/api/providers/unbound.ts:205: NoCoverage CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
} else {
externalAbortSignal.addEventListener("abort", abortListener, { once: true })
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let lastUsage: any = undefined

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

const reasoningText = extractReasoningFromDelta(delta)
if (reasoningText) {
yield { type: "reasoning", text: reasoningText }
try {
let stream
try {
stream = await this.client.chat.completions.create(completionParams, { signal: controller.signal })
} 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

if (delta?.content) {
yield { type: "text", text: delta.content }
}
try {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta

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

// 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 (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,
}
}
}

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 {
externalAbortSignal?.removeEventListener("abort", abortListener)
}
}

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 +289,34 @@
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.

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