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,035 changes: 1,012 additions & 23 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.

296 changes: 232 additions & 64 deletions src/api/providers/opencode-go.ts

Large diffs are not rendered by default.

147 changes: 112 additions & 35 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 All @@ -23,6 +23,7 @@ import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../index"
import { handleOpenAIError } from "./utils/error-handler"
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
import { createAbortError, isRequestAborted, resolveModelWithAbort } from "./utils/abort-signal"
import { extractReasoningFromDelta } from "./utils/extract-reasoning"

// Unbound usage includes extra fields for Anthropic cache tokens.
Expand Down Expand Up @@ -125,14 +126,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 @@ -158,51 +166,98 @@ export class UnboundHandler extends BaseProvider implements SingleCompletionHand
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()
const abortListener = () => controller.abort()
if (externalAbortSignal) {
// Stryker disable next-line ConditionalExpression: externalAbortSignal.aborted can never be true here - the entry guard rejects a pre-aborted signal and the rejectOnAbort race rejects an abort during model resolution, and no await sits between the race settling and this bridge, so the branch is unreachable
if (externalAbortSignal.aborted) {
// Stryker disable next-line CallExpression: unreachable branch body - a pre-aborted external signal is rejected by the entry guard (and a mid-resolution abort by the race) before this bridge registers
controller.abort()
} 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 }
}

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

async completePrompt(prompt: string, options?: CompletePromptOptions): Promise<string> {
const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel()
// 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 @@ -212,11 +267,33 @@ 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
}
if (options?.timeoutMs !== undefined && options.timeoutMs > 0) {
createOptions.timeout = options.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