| import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' |
| import * as fs from 'fs/promises' |
| import * as os from 'os' |
| import * as path from 'path' |
| import { handleProxyRequest } from '../proxy/handler.js' |
| import { ProviderService } from '../services/providerService.js' |
| import { resetSettingsCache } from '../../utils/settings/settingsCache.js' |
|
|
| let tmpDir: string |
| let originalConfigDir: string | undefined |
|
|
| async function setup() { |
| tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'proxy-network-test-')) |
| originalConfigDir = process.env.CLAUDE_CONFIG_DIR |
| process.env.CLAUDE_CONFIG_DIR = tmpDir |
| resetSettingsCache() |
| } |
|
|
| async function teardown() { |
| if (originalConfigDir !== undefined) { |
| process.env.CLAUDE_CONFIG_DIR = originalConfigDir |
| } else { |
| delete process.env.CLAUDE_CONFIG_DIR |
| } |
| resetSettingsCache() |
| await fs.rm(tmpDir, { recursive: true, force: true }) |
| } |
|
|
| describe('proxy network settings', () => { |
| beforeEach(setup) |
| afterEach(teardown) |
|
|
| test('uses configured AI request timeout for non-stream upstream requests', async () => { |
| await fs.writeFile( |
| path.join(tmpDir, 'settings.json'), |
| JSON.stringify({ |
| network: { |
| aiRequestTimeoutMs: 45_000, |
| proxy: { mode: 'system', url: '' }, |
| }, |
| }), |
| 'utf-8', |
| ) |
|
|
| const svc = new ProviderService() |
| const provider = await svc.addProvider({ |
| presetId: 'custom', |
| name: 'OpenAI Proxy', |
| baseUrl: 'https://api.example.com', |
| apiKey: 'sk-test', |
| apiFormat: 'openai_chat', |
| models: { |
| main: 'model-main', |
| haiku: 'model-main', |
| sonnet: 'model-main', |
| opus: 'model-main', |
| }, |
| }) |
|
|
| const originalFetch = globalThis.fetch |
| const originalTimeout = AbortSignal.timeout |
| const timeoutCalls: number[] = [] |
| globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { |
| return new Response(JSON.stringify({ |
| id: 'chatcmpl-network-timeout', |
| object: 'chat.completion', |
| created: 0, |
| model: 'model-main', |
| choices: [{ |
| index: 0, |
| message: { role: 'assistant', content: 'ok' }, |
| finish_reason: 'stop', |
| }], |
| usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, |
| }), { |
| status: 200, |
| headers: { 'Content-Type': 'application/json' }, |
| }) |
| }) as typeof fetch |
| AbortSignal.timeout = ((ms: number) => { |
| timeoutCalls.push(ms) |
| return originalTimeout(ms) |
| }) as typeof AbortSignal.timeout |
|
|
| try { |
| const body = { |
| model: 'model-main', |
| max_tokens: 64, |
| messages: [{ role: 'user', content: 'hello' }], |
| } |
| const req = new Request( |
| `http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, |
| { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }, |
| ) |
| const res = await handleProxyRequest(req, new URL(req.url)) |
|
|
| expect(res.status).toBe(200) |
| expect(timeoutCalls).toEqual([45_000]) |
| } finally { |
| AbortSignal.timeout = originalTimeout |
| globalThis.fetch = originalFetch |
| } |
| }) |
|
|
| test('uses configured AI request timeout for non-stream Responses upstream requests', async () => { |
| await fs.writeFile( |
| path.join(tmpDir, 'settings.json'), |
| JSON.stringify({ |
| network: { |
| aiRequestTimeoutMs: 45_000, |
| proxy: { mode: 'system', url: '' }, |
| }, |
| }), |
| 'utf-8', |
| ) |
|
|
| const svc = new ProviderService() |
| const provider = await svc.addProvider({ |
| presetId: 'custom', |
| name: 'OpenAI Responses Proxy', |
| baseUrl: 'https://api.example.com', |
| apiKey: 'sk-test', |
| apiFormat: 'openai_responses', |
| models: { |
| main: 'model-main', |
| haiku: 'model-main', |
| sonnet: 'model-main', |
| opus: 'model-main', |
| }, |
| }) |
|
|
| const originalFetch = globalThis.fetch |
| const originalTimeout = AbortSignal.timeout |
| const timeoutCalls: number[] = [] |
| globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { |
| return new Response(JSON.stringify({ |
| id: 'resp-network-timeout', |
| status: 'completed', |
| model: 'model-main', |
| output: [{ |
| type: 'message', |
| content: [{ type: 'output_text', text: 'ok' }], |
| }], |
| usage: { input_tokens: 1, output_tokens: 1 }, |
| }), { |
| status: 200, |
| headers: { 'Content-Type': 'application/json' }, |
| }) |
| }) as typeof fetch |
| AbortSignal.timeout = ((ms: number) => { |
| timeoutCalls.push(ms) |
| return originalTimeout(ms) |
| }) as typeof AbortSignal.timeout |
|
|
| try { |
| const body = { |
| model: 'model-main', |
| max_tokens: 64, |
| messages: [{ role: 'user', content: 'hello' }], |
| } |
| const req = new Request( |
| `http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, |
| { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }, |
| ) |
| const res = await handleProxyRequest(req, new URL(req.url)) |
|
|
| expect(res.status).toBe(200) |
| expect(timeoutCalls).toEqual([45_000]) |
| } finally { |
| AbortSignal.timeout = originalTimeout |
| globalThis.fetch = originalFetch |
| } |
| }) |
|
|
| test('uses configured AI request timeout for streaming upstream requests', async () => { |
| await fs.writeFile( |
| path.join(tmpDir, 'settings.json'), |
| JSON.stringify({ |
| network: { |
| aiRequestTimeoutMs: 180_000, |
| proxy: { mode: 'system', url: '' }, |
| }, |
| }), |
| 'utf-8', |
| ) |
|
|
| const svc = new ProviderService() |
| const provider = await svc.addProvider({ |
| presetId: 'custom', |
| name: 'OpenAI Proxy', |
| baseUrl: 'https://api.example.com', |
| apiKey: 'sk-test', |
| apiFormat: 'openai_chat', |
| models: { |
| main: 'model-main', |
| haiku: 'model-main', |
| sonnet: 'model-main', |
| opus: 'model-main', |
| }, |
| }) |
|
|
| const originalFetch = globalThis.fetch |
| const originalTimeout = AbortSignal.timeout |
| const timeoutCalls: number[] = [] |
| globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => { |
| return new Response( |
| new ReadableStream({ |
| start(controller) { |
| controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n')) |
| controller.close() |
| }, |
| }), |
| { |
| status: 200, |
| headers: { 'Content-Type': 'text/event-stream' }, |
| }, |
| ) |
| }) as typeof fetch |
| AbortSignal.timeout = ((ms: number) => { |
| timeoutCalls.push(ms) |
| return originalTimeout(ms) |
| }) as typeof AbortSignal.timeout |
|
|
| try { |
| const body = { |
| model: 'model-main', |
| max_tokens: 64, |
| stream: true, |
| messages: [{ role: 'user', content: 'hello' }], |
| } |
| const req = new Request( |
| `http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, |
| { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }, |
| ) |
| const res = await handleProxyRequest(req, new URL(req.url)) |
|
|
| expect(res.status).toBe(200) |
| expect(timeoutCalls).toEqual([180_000]) |
| } finally { |
| AbortSignal.timeout = originalTimeout |
| globalThis.fetch = originalFetch |
| } |
| }) |
| }) |
|
|