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.

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.

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