Skip to content
Merged
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
25 changes: 20 additions & 5 deletions src/providers/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { ChatParams, ChatResult, Provider, ProviderStreamChunk } from '../types.js';
import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, fetchWithTimeout } from './request.js';
import {
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
ProviderResponseTimeoutError,
fetchWithTimeout,
readResponseJsonWithTimeout,
readResponseTextWithTimeout,
} from './request.js';
import { parseAnthropicSseLine, streamSseResponse } from './sse.js';

function buildAnthropicRequestBody(params: ChatParams, options: { stream?: boolean } = {}): Record<string, unknown> {
Expand Down Expand Up @@ -32,6 +38,7 @@ function buildAnthropicRequestBody(params: ChatParams, options: { stream?: boole
export class AnthropicProvider implements Provider {
async complete(params: ChatParams): Promise<ChatResult> {
const { model, apiKey, baseUrl } = params;
const controller = new AbortController();

const url = `${baseUrl.replace(/\/+$/, '')}/messages`;
const body = buildAnthropicRequestBody(params);
Expand All @@ -49,19 +56,27 @@ export class AnthropicProvider implements Provider {
},
'Anthropic API request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
new AbortController(),
controller,
params.signal,
false,
);

if (!response.ok) {
const errorBody = await response.text().catch(() => '');
let errorBody = '';
try {
errorBody = await readResponseTextWithTimeout(response, controller, 'Anthropic API response');
} catch (error) {
if (error instanceof ProviderResponseTimeoutError) {
throw error;
}
}
throw new Error(`Anthropic API error (${response.status}): ${errorBody || response.statusText}`);
}

const data = (await response.json()) as {
const data = await readResponseJsonWithTimeout<{
content?: { type: string; text: string }[];
model?: string;
};
}>(response, controller, 'Anthropic API response');

const textContent = data.content?.find((c) => c.type === 'text');
if (!textContent?.text) {
Expand Down
34 changes: 27 additions & 7 deletions src/providers/cohere.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { ChatParams, ChatResult, Provider } from '../types.js';
import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, fetchWithTimeout } from './request.js';
import {
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
ProviderResponseTimeoutError,
fetchWithTimeout,
readResponseJsonWithTimeout,
readResponseTextWithTimeout,
} from './request.js';

export class CohereProvider implements Provider {
async complete(params: ChatParams): Promise<ChatResult> {
const { model, messages, temperature = 0.7, maxTokens = 1024, apiKey, baseUrl } = params;
const controller = new AbortController();

const url = `${baseUrl.replace(/\/+$/, '')}/chat`;

Expand Down Expand Up @@ -41,19 +48,27 @@ export class CohereProvider implements Provider {
},
'Cohere API request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
new AbortController(),
controller,
params.signal,
false,
);

if (!response.ok) {
const errorBody = await response.text().catch(() => '');
let errorBody = '';
try {
errorBody = await readResponseTextWithTimeout(response, controller, 'Cohere API response');
} catch (error) {
if (error instanceof ProviderResponseTimeoutError) {
throw error;
}
}
throw new Error(`Cohere API error (${response.status}): ${errorBody || response.statusText}`);
}

const data = (await response.json()) as {
const data = await readResponseJsonWithTimeout<{
text?: string;
meta?: { api_version?: { version?: string } };
};
}>(response, controller, 'Cohere API response');

if (!data.text) {
throw new Error('Cohere returned empty response.');
Expand All @@ -66,6 +81,7 @@ export class CohereProvider implements Provider {
}

async fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
const controller = new AbortController();
const url = `${baseUrl.replace(/\/+$/, '')}/models`;

const response = await fetchWithTimeout(
Expand All @@ -76,15 +92,19 @@ export class CohereProvider implements Provider {
},
},
'Cohere model request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
controller,
undefined,
false,
);

if (!response.ok) {
throw new Error(`Failed to fetch models (${response.status}): ${response.statusText}`);
}

const data = (await response.json()) as {
const data = await readResponseJsonWithTimeout<{
models?: { name?: string; id?: string }[];
};
}>(response, controller, 'Cohere model response');

if (data.models && Array.isArray(data.models)) {
return data.models
Expand Down
40 changes: 32 additions & 8 deletions src/providers/openai-compatible.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { ChatParams, ChatResult, Provider, ProviderStreamChunk } from '../types.js';
import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, fetchWithTimeout } from './request.js';
import {
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
ProviderResponseTimeoutError,
fetchWithTimeout,
readResponseJsonWithTimeout,
readResponseTextWithTimeout,
} from './request.js';
import { parseOpenAiSseLine, streamSseResponse, SSE_STREAM_END } from './sse.js';

const MAX_BUFFERED_REASONING_CHARS = 1024 * 1024;
Expand All @@ -25,6 +31,7 @@ function buildOpenAiRequestBody(params: ChatParams, options: { stream?: boolean
export class OpenAICompatibleProvider implements Provider {
async complete(params: ChatParams): Promise<ChatResult> {
const { model, apiKey, baseUrl } = params;
const controller = new AbortController();

const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;

Expand All @@ -44,19 +51,27 @@ export class OpenAICompatibleProvider implements Provider {
},
'OpenAI-compatible API request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
new AbortController(),
controller,
params.signal,
false,
);

if (!response.ok) {
const errorBody = await response.text().catch(() => '');
let errorBody = '';
try {
errorBody = await readResponseTextWithTimeout(response, controller, 'OpenAI-compatible API response');
} catch (error) {
if (error instanceof ProviderResponseTimeoutError) {
throw error;
}
}
throw new Error(`OpenAI-compatible API error (${response.status}): ${errorBody || response.statusText}`);
}

const data = (await response.json()) as {
const data = await readResponseJsonWithTimeout<{
choices?: { message?: { content?: string } }[];
model?: string;
};
}>(response, controller, 'OpenAI-compatible API response');

const content = data.choices?.[0]?.message?.content;
if (!content) {
Expand Down Expand Up @@ -140,6 +155,7 @@ export class OpenAICompatibleProvider implements Provider {
}

async fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
const controller = new AbortController();
const url = `${baseUrl.replace(/\/+$/, '')}/models`;

const headers: Record<string, string> = {
Expand All @@ -149,15 +165,23 @@ export class OpenAICompatibleProvider implements Provider {
headers['Authorization'] = `Bearer ${apiKey}`;
}

const response = await fetchWithTimeout(url, { headers }, 'OpenAI-compatible model request');
const response = await fetchWithTimeout(
url,
{ headers },
'OpenAI-compatible model request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
controller,
undefined,
false,
);

if (!response.ok) {
throw new Error(`Failed to fetch models (${response.status}): ${response.statusText}`);
}

const data = (await response.json()) as {
const data = await readResponseJsonWithTimeout<{
data?: { id: string; object?: string }[];
};
}>(response, controller, 'OpenAI-compatible model response');

if (!data.data || !Array.isArray(data.data)) {
throw new Error('Unexpected response format when fetching models');
Expand Down
70 changes: 68 additions & 2 deletions src/providers/request.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export const DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS = 30_000;

export class ProviderResponseTimeoutError extends Error {
constructor(label: string, timeoutMs: number) {
super(`${label} timed out after ${timeoutMs}ms`);
this.name = 'ProviderResponseTimeoutError';
}
}
export async function fetchWithTimeout(
url: string,
init: RequestInit,
Expand All @@ -10,7 +16,7 @@ export async function fetchWithTimeout(
keepTimeoutThroughBody = true,
): Promise<Response> {
let timedOut = false;
let timeoutError: Error | undefined;
let timeoutError: ProviderResponseTimeoutError | undefined;
let externalAbortListener: (() => void) | undefined;

if (externalSignal) {
Expand All @@ -37,7 +43,7 @@ export async function fetchWithTimeout(

timeout = setTimeout(() => {
timedOut = true;
timeoutError = new Error(`${label} timed out after ${timeoutMs}ms`);
timeoutError = new ProviderResponseTimeoutError(label, timeoutMs);
controller.abort(timeoutError);
}, timeoutMs);

Expand Down Expand Up @@ -104,3 +110,63 @@ export async function fetchWithTimeout(
throw error;
}
}

export async function readResponseTextWithTimeout(
response: Response,
controller: AbortController,
label: string,
timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
): Promise<string> {
const reader = response.body?.getReader();
if (!reader) {
return '';
}

let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
void reader.cancel().catch(() => undefined);
}, timeoutMs);

try {
const decoder = new TextDecoder();
let text = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
text += decoder.decode(value, { stream: true });
}
if (timedOut) {
throw new ProviderResponseTimeoutError(label, timeoutMs);
}
}

if (timedOut) {
throw new ProviderResponseTimeoutError(label, timeoutMs);
}

text += decoder.decode();
return text;
} catch (error) {
if (timedOut) {
throw new ProviderResponseTimeoutError(label, timeoutMs);
}
throw error;
} finally {
clearTimeout(timeout);
reader.releaseLock();
}
}

export async function readResponseJsonWithTimeout<T>(
response: Response,
controller: AbortController,
label: string,
timeoutMs = DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
): Promise<T> {
const text = await readResponseTextWithTimeout(response, controller, label, timeoutMs);
return JSON.parse(text) as T;
}
Loading
Loading