diff --git a/src/localRecoveryCli.test.ts b/src/localRecoveryCli.test.ts new file mode 100644 index 00000000..dade652a --- /dev/null +++ b/src/localRecoveryCli.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, spyOn, test } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Readable } from 'node:stream' +import { createSandboxedTestEnvironment } from '../scripts/pr/test-environment.js' +import { run } from './localRecoveryCli.js' + +describe('local recovery Anthropic base URL compatibility', () => { + for (const mode of ['print', 'interactive'] as const) { + for (const baseUrl of [undefined, 'https://provider.example/anthropic/v1/']) { + test(`${mode} entry point uses ${baseUrl ?? 'the default Anthropic endpoint'} with supplied input`, async () => { + const sandboxHome = await mkdtemp(join(tmpdir(), 'recovery-entry-point-')) + const previousEnv = { ...process.env } + const testEnv = createSandboxedTestEnvironment(sandboxHome, { + ANTHROPIC_API_KEY: 'sk-test-recovery', + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ...(baseUrl ? { ANTHROPIC_BASE_URL: baseUrl } : {}), + }) + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, testEnv) + let stdout = '' + let stderr = '' + const requests: Array<{ url: string; body: unknown }> = [] + const stdoutSpy = spyOn(process.stdout, 'write').mockImplementation(chunk => { + stdout += String(chunk) + return true + }) + const stderrSpy = spyOn(process.stderr, 'write').mockImplementation(chunk => { + stderr += String(chunk) + return true + }) + const fetchSpy = spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + requests.push({ + url: input instanceof Request ? input.url : String(input), + body: JSON.parse(String(init?.body)), + }) + return Response.json({ + id: 'msg-recovery-entry-point', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'entry point succeeded' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }) + }) + + try { + await run( + mode === 'print' ? ['--print'] : [], + Readable.from([mode === 'print' ? 'hello\n' : 'hello\n/exit\n']), + ) + expect(stderr).toBe('') + expect(stdout).toContain('entry point succeeded') + expect(requests).toEqual([{ + url: baseUrl ? 'https://provider.example/anthropic/v1/messages' : 'https://api.anthropic.com/v1/messages', + body: expect.objectContaining({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'hello' }], + }), + }]) + } finally { + fetchSpy.mockRestore() + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, previousEnv) + await rm(sandboxHome, { recursive: true, force: true }) + } + }) + } + } + + for (const mode of ['print', 'interactive'] as const) { + for (const basePath of ['/anthropic', '/anthropic/v1/']) { + test(`${mode} sends messages through ${basePath} without duplicating the API version`, async () => { + const sandboxHome = await mkdtemp(join(tmpdir(), 'recovery-base-url-')) + const requests: Array<{ path: string; body: unknown }> = [] + const upstream = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request) { + const pathname = new URL(request.url).pathname + requests.push({ path: pathname, body: await request.json() }) + if (pathname !== '/anthropic/v1/messages') { + return Response.json({ error: { type: 'not_found_error', message: 'wrong endpoint' } }, { status: 404 }) + } + return Response.json({ + id: 'msg-recovery-test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'recovery request succeeded' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }) + }, + }) + let child: ReturnType | undefined + + try { + child = Bun.spawn([ + process.execPath, + '--no-env-file', + join(import.meta.dir, 'localRecoveryCli.ts'), + ...(mode === 'print' ? ['--print', 'hello'] : []), + ], { + cwd: join(import.meta.dir, '..'), + env: createSandboxedTestEnvironment(sandboxHome, { + ANTHROPIC_API_KEY: 'sk-test-recovery', + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstream.port}${basePath}`, + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + API_TIMEOUT_MS: '2000', + }), + stdin: mode === 'interactive' ? new Blob(['hello\n/exit\n']) : 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toContain('recovery request succeeded') + expect(requests).toEqual([{ + path: '/anthropic/v1/messages', + body: expect.objectContaining({ + model: 'claude-sonnet-4-6', + messages: [{ role: 'user', content: 'hello' }], + }), + }]) + } finally { + child?.kill() + upstream.stop(true) + await rm(sandboxHome, { recursive: true, force: true }) + } + }) + } + } +}) diff --git a/src/localRecoveryCli.ts b/src/localRecoveryCli.ts index 69b94baf..3cd347f9 100644 --- a/src/localRecoveryCli.ts +++ b/src/localRecoveryCli.ts @@ -1,8 +1,10 @@ import Anthropic from '@anthropic-ai/sdk' import { readFileSync } from 'fs' import { createInterface } from 'readline' +import { normalizeAnthropicBaseUrl } from './services/api/anthropicBaseUrl.js' type OutputFormat = 'text' | 'json' +type RecoveryInput = NodeJS.ReadableStream & { isTTY?: boolean } function printHelp(): void { process.stdout.write( @@ -105,10 +107,10 @@ function parseArgs(argv: string[]) { } } -async function readPromptFromStdin(): Promise { - if (process.stdin.isTTY) return '' +async function readPromptFromStdin(input: RecoveryInput): Promise { + if (input.isTTY) return '' const chunks: Buffer[] = [] - for await (const chunk of process.stdin) { + for await (const chunk of input) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))) } return Buffer.concat(chunks).toString('utf8').trim() @@ -124,8 +126,11 @@ function getSystemPrompt( return systemPrompt ?? appendSystemPrompt } -async function run(): Promise { - const parsed = parseArgs(process.argv.slice(2)) +export async function run( + argv = process.argv.slice(2), + input: RecoveryInput = process.stdin, +): Promise { + const parsed = parseArgs(argv) if (parsed.command === 'help') { printHelp() @@ -137,11 +142,11 @@ async function run(): Promise { } if (!parsed.print) { - await runInteractive(parsed) + await runInteractive(parsed, input) return } - const prompt = parsed.prompt || (await readPromptFromStdin()) + const prompt = parsed.prompt || (await readPromptFromStdin(input)) if (!prompt) { process.stderr.write('Error: prompt is required\n') process.exitCode = 1 @@ -172,7 +177,9 @@ async function run(): Promise { const client = new Anthropic({ apiKey: apiKey ?? undefined, authToken: authToken ?? undefined, - baseURL: process.env.ANTHROPIC_BASE_URL || undefined, + baseURL: process.env.ANTHROPIC_BASE_URL + ? normalizeAnthropicBaseUrl(process.env.ANTHROPIC_BASE_URL) + : undefined, timeout: parseInt(process.env.API_TIMEOUT_MS || String(600_000), 10), maxRetries: 0, }) @@ -201,7 +208,7 @@ async function runInteractive(parsed: { model?: string systemPrompt?: string appendSystemPrompt?: string -}): Promise { +}, input: RecoveryInput): Promise { const apiKey = process.env.ANTHROPIC_API_KEY const authToken = process.env.ANTHROPIC_AUTH_TOKEN if (!apiKey && !authToken) { @@ -226,7 +233,9 @@ async function runInteractive(parsed: { const client = new Anthropic({ apiKey: apiKey ?? undefined, authToken: authToken ?? undefined, - baseURL: process.env.ANTHROPIC_BASE_URL || undefined, + baseURL: process.env.ANTHROPIC_BASE_URL + ? normalizeAnthropicBaseUrl(process.env.ANTHROPIC_BASE_URL) + : undefined, timeout: parseInt(process.env.API_TIMEOUT_MS || String(600_000), 10), maxRetries: 0, }) @@ -234,7 +243,7 @@ async function runInteractive(parsed: { const system = getSystemPrompt(parsed.systemPrompt, parsed.appendSystemPrompt) const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [] const rl = createInterface({ - input: process.stdin, + input, output: process.stdout, prompt: 'you> ', }) @@ -284,8 +293,10 @@ async function runInteractive(parsed: { } } -void run().catch(error => { - const message = error instanceof Error ? error.stack || error.message : String(error) - process.stderr.write(`${message}\n`) - process.exitCode = 1 -}) +if (import.meta.main) { + void run().catch(error => { + const message = error instanceof Error ? error.stack || error.message : String(error) + process.stderr.write(`${message}\n`) + process.exitCode = 1 + }) +} diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index e5fd792b..82235c58 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -2415,6 +2415,76 @@ describe('ProviderService', () => { }) describe('testProviderConfig', () => { + for (const [basePath, messagePath] of [ + ['', '/v1/messages'], + ['/', '/v1/messages'], + ['/v1', '/v1/messages'], + ['/v1///', '/v1/messages'], + ['/anthropic', '/anthropic/v1/messages'], + ['/anthropic/v1/', '/anthropic/v1/messages'], + ['/v1/tenant', '/v1/tenant/v1/messages'], + ]) { + test(`Anthropic base URL ${basePath || '(root)'} works in connectivity and proxy requests (#1279)`, async () => { + const requests: Array<{ path: string; apiKey: string | null; version: string | null }> = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const requestPath = new URL(req.url).pathname + requests.push({ + path: requestPath, + apiKey: req.headers.get('x-api-key'), + version: req.headers.get('anthropic-version'), + }) + if (requestPath !== messagePath) { + return Response.json({ error: { message: 'Unknown endpoint' } }, { status: 404 }) + } + const body = await req.json() as { stream?: boolean } + if (body.stream) { + return new Response('event: message_stop\ndata: {"type":"message_stop"}\n\n', { + headers: { 'Content-Type': 'text/event-stream' }, + }) + } + return Response.json({ type: 'message', model: 'model-main', content: [{ type: 'text', text: 'ok' }] }) + }, + }) + + try { + const svc = new ProviderService() + const baseUrl = `http://127.0.0.1:${server.port}${basePath}` + const provider = await svc.addProvider(sampleInput({ + baseUrl, + authStrategy: 'api_key', + supportsNestedToolResultMedia: false, + })) + const result = await svc.testProvider(provider.id) + expect(requests[0]?.path).toBe(messagePath) + expect(result.connectivity.success).toBe(true) + expect(result.proxy?.success).toBe(true) + + for (const stream of [false, true]) { + const req = new Request(`http://localhost/proxy/providers/${provider.id}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' }, + body: JSON.stringify({ model: 'model-main', max_tokens: 16, stream, messages: [{ role: 'user', content: 'hello' }] }), + }) + const response = await handleProxyRequest(req, new URL(req.url)) + expect(response.status).toBe(200) + if (stream) expect(await response.text()).toContain('event: message_stop') + else expect(await response.json()).toMatchObject({ type: 'message' }) + } + expect(requests).toEqual(Array.from({ length: 4 }, () => ({ + path: messagePath, + apiKey: 'sk-test-key-123', + version: '2023-06-01', + }))) + expect((await svc.getProvider(provider.id))?.baseUrl).toBe(baseUrl) + } finally { + server.stop(true) + } + }) + } + test('should use auth strategy headers for Anthropic-compatible tests', async () => { const originalFetch = globalThis.fetch const calls: Array<{ url: string; headers: Record }> = [] diff --git a/src/server/__tests__/title-service.test.ts b/src/server/__tests__/title-service.test.ts index ca6483e7..9819e0fe 100644 --- a/src/server/__tests__/title-service.test.ts +++ b/src/server/__tests__/title-service.test.ts @@ -37,6 +37,33 @@ describe('titleService', () => { await fs.rm(tmpDir, { recursive: true, force: true }) }) + test('generates titles with a versioned Anthropic base URL (#1279)', async () => { + const paths: string[] = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch(req) { + const requestPath = new URL(req.url).pathname + paths.push(requestPath) + if (requestPath !== '/anthropic/v1/messages') { + return Response.json({ error: 'Unknown endpoint' }, { status: 404 }) + } + return Response.json({ content: [{ type: 'text', text: '{"title":"Endpoint compatibility"}' }] }) + }, + }) + try { + const provider = await new ProviderService().addProvider({ + presetId: 'custom', name: 'Versioned Anthropic', apiKey: 'test-key', + baseUrl: `http://127.0.0.1:${server.port}/anthropic/v1/`, apiFormat: 'anthropic', + models: { main: 'test-model', haiku: 'test-model', sonnet: 'test-model', opus: 'test-model' }, + }) + expect(await generateTitle('Explain endpoint compatibility', provider.id)).toBe('Endpoint compatibility') + expect(paths).toEqual(['/anthropic/v1/messages']) + } finally { + server.stop(true) + } + }) + test('sends disabled thinking for title generation by default', async () => { let requestBody: Record | null = null const server = Bun.serve({ diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts index aa7c8844..5ad090c2 100644 --- a/src/server/proxy/handler.ts +++ b/src/server/proxy/handler.ts @@ -10,6 +10,7 @@ */ import { getOpenAIPolicyError } from '../../services/openaiAuth/policyError.js' +import { normalizeAnthropicBaseUrl } from '../../services/api/anthropicBaseUrl.js' import { createGunzip, createInflate } from 'node:zlib' import { ProviderService } from '../services/providerService.js' @@ -442,7 +443,7 @@ async function handleAnthropicCompatible( traceContext: ProxyTraceContext | null, ): Promise { const transformed = hoistToolResultMediaForCompatibility(body) - const url = `${baseUrl}/v1/messages` + const url = `${normalizeAnthropicBaseUrl(baseUrl)}/v1/messages` const proxyOptions = getNetworkProxyFetchOptions(networkSettings, url) const headers: Record = { diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 7a5ab91c..703f07d2 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -10,6 +10,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' +import { normalizeAnthropicBaseUrl } from '../../services/api/anthropicBaseUrl.js' import { readRecoverableJsonFile } from './recoverableJsonFile.js' import { ManagedSettingsService } from './managedSettingsService.js' import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js' @@ -757,7 +758,7 @@ export class ProviderService { headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` } } else { transformedBody = hoistToolResultMediaForCompatibility(anthropicReq) - upstreamUrl = `${base}/v1/messages` + upstreamUrl = `${normalizeAnthropicBaseUrl(base)}/v1/messages` headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', @@ -836,7 +837,7 @@ function buildDirectTestRequest( } // anthropic return { - url: `${base}/v1/messages`, + url: `${normalizeAnthropicBaseUrl(base)}/v1/messages`, headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', diff --git a/src/server/services/titleService.ts b/src/server/services/titleService.ts index 4f9dbe02..1413676d 100644 --- a/src/server/services/titleService.ts +++ b/src/server/services/titleService.ts @@ -7,6 +7,7 @@ */ import { ProviderService } from './providerService.js' +import { normalizeAnthropicBaseUrl } from '../../services/api/anthropicBaseUrl.js' import { getPresetAuthStrategy } from './providerRuntimeEnv.js' import { getNetworkProxyFetchOptions, @@ -195,7 +196,7 @@ export async function generateTitle( if (!resolvedProvider?.baseUrl || !resolvedProvider?.apiKey) return null const model = resolvedProvider.models.haiku || resolvedProvider.models.main - const url = `${resolvedProvider.baseUrl.replace(/\/+$/, '')}/v1/messages` + const url = `${normalizeAnthropicBaseUrl(resolvedProvider.baseUrl.replace(/\/+$/, ''))}/v1/messages` const authStrategy = resolvedProvider.authStrategy ?? getPresetAuthStrategy(resolvedProvider.presetId) const requestHeaders = buildAnthropicTitleRequestHeaders(resolvedProvider.apiKey, authStrategy) const requestBody = { diff --git a/src/services/api/anthropicBaseUrl.test.ts b/src/services/api/anthropicBaseUrl.test.ts new file mode 100644 index 00000000..e44e516e --- /dev/null +++ b/src/services/api/anthropicBaseUrl.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeAnthropicBaseUrl } from './anthropicBaseUrl.js' + +describe('normalizeAnthropicBaseUrl', () => { + test.each([ + ['https://gateway.example', 'https://gateway.example'], + ['https://gateway.example/', 'https://gateway.example/'], + ['https://gateway.example/v1', 'https://gateway.example'], + ['https://gateway.example/v1///', 'https://gateway.example'], + ['http://127.0.0.1:1234/anthropic/v1/', 'http://127.0.0.1:1234/anthropic'], + ['https://gateway.example/a%2Fb/v1', 'https://gateway.example/a%2Fb'], + ['https://gateway.example/v1/tenant', 'https://gateway.example/v1/tenant'], + ['https://gateway.example/v10', 'https://gateway.example/v10'], + ['https://gateway.example/v1/messages', 'https://gateway.example/v1/messages'], + ['https://gateway.example/?route=/v1', 'https://gateway.example/?route=/v1'], + ['https://gateway.example/v1?route=one', 'https://gateway.example/v1?route=one'], + ['https://gateway.example/#/v1', 'https://gateway.example/#/v1'], + ['invalid/v1', 'invalid/v1'], + ['file:///v1', 'file:///v1'], + ['', ''], + ])('%s → %s', (input, expected) => { + expect(normalizeAnthropicBaseUrl(input)).toBe(expected) + }) +}) diff --git a/src/services/api/anthropicBaseUrl.ts b/src/services/api/anthropicBaseUrl.ts new file mode 100644 index 00000000..77a48608 --- /dev/null +++ b/src/services/api/anthropicBaseUrl.ts @@ -0,0 +1,23 @@ +/** + * Anthropic SDK and HTTP callers append /v1 themselves. Accept bases ending in + * /v1 without duplicating that final segment; keep gateway prefixes intact. + * Normalize only at the request boundary, never in persisted provider settings. + */ +export function normalizeAnthropicBaseUrl(baseUrl: string): string { + try { + const url = new URL(baseUrl) + // Query/fragment bases are not supported by the SDK's string concatenation. + // Leave them unchanged rather than silently rewriting routing parameters. + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.search || url.hash || + !url.pathname.replace(/\/+$/, '').endsWith('/v1') + ) return baseUrl + + // Preserve the original spelling/encoding of the gateway prefix. + return baseUrl.replace(/\/v1\/*$/, '') + } catch { + // Keep invalid-input handling at the existing SDK/fetch boundary. + return baseUrl + } +} diff --git a/src/services/api/client.test.ts b/src/services/api/client.test.ts index 5f14cee1..d7effb6c 100644 --- a/src/services/api/client.test.ts +++ b/src/services/api/client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from 'bun:test' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' +import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js' import { GROK_OAUTH_DUMMY_KEY } from '../grokAuth/fetch.js' mock.module('src/utils/http.js', () => ({ @@ -133,6 +134,74 @@ describe('shouldUseOpenAICodexTransport', () => { }) describe('getAnthropicClient', () => { + test('uses one API version in direct message and token-count requests while preserving path prefixes', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'anthropic-client-base-url-')) + const previousEnv = { ...process.env } + const testEnv = createSandboxedTestEnvironment(tempDir, { + ANTHROPIC_API_KEY: 'sk-test-provider', + CLAUDE_CODE_SIMPLE: '1', + OPENAI_CODEX_OAUTH_FILE: path.join(tempDir, 'missing-openai-oauth.json'), + }) + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, testEnv) + + try { + const { getAnthropicClient } = await import('./client.js') + for (const [basePath, expectedPrefix] of [ + ['', ''], + ['/', ''], + ['/v1', ''], + ['/v1/', ''], + ['/anthropic', '/anthropic'], + ['/anthropic/v1/', '/anthropic'], + ['/v1/tenant', '/v1/tenant'], + ['/proxy/providers/provider-1', '/proxy/providers/provider-1'], + ]) { + process.env.ANTHROPIC_BASE_URL = `https://provider.example${basePath}` + const receivedUrls: string[] = [] + const client = await getAnthropicClient({ + apiKey: 'sk-test-provider', + maxRetries: 0, + model: 'claude-sonnet-4-6', + fetchOverride: async input => { + const url = new URL(input instanceof Request ? input.url : String(input)) + receivedUrls.push(url.href) + return url.pathname.endsWith('/count_tokens') + ? Response.json({ input_tokens: 3 }) + : Response.json({ + id: 'msg-test-base-url', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 3, output_tokens: 1 }, + }) + }, + }) + const messages = [{ role: 'user' as const, content: 'hello' }] + const response = await client.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 16, + messages, + }) + const count = await client.beta.messages.countTokens({ model: 'claude-sonnet-4-6', messages }) + + expect(response.id).toBe('msg-test-base-url') + expect(count.input_tokens).toBe(3) + expect(receivedUrls).toEqual([ + `https://provider.example${expectedPrefix}/v1/messages`, + `https://provider.example${expectedPrefix}/v1/messages/count_tokens?beta=true`, + ]) + } + } finally { + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, previousEnv) + await fs.rm(tempDir, { recursive: true, force: true }) + } + }) + test('selects the isolated Grok transport with a dummy SDK key', async () => { const { getAnthropicClient } = await import('./client.js') const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grok-client-test-')) diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 0a6d3e0b..b2c7c820 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -1,4 +1,5 @@ import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk' +import { normalizeAnthropicBaseUrl } from './anthropicBaseUrl.js' import { randomUUID } from 'crypto' import type { GoogleAuth } from 'google-auth-library' import { @@ -451,8 +452,8 @@ export async function getAnthropicClient({ authToken: isClaudeSubscriber && !usingOpenAICodex && !usingGrok ? getClaudeAIOAuthTokens()?.accessToken : undefined, - // Set baseURL from OAuth config when using staging OAuth - ...(stagingOAuthBaseUrl ? { baseURL: stagingOAuthBaseUrl } : {}), + // The SDK appends /v1 to every API path, including messages and token counts. + ...(baseURL ? { baseURL: normalizeAnthropicBaseUrl(baseURL) } : {}), ...ARGS, ...(isDebugToStdErr() && { logger: createStderrLogger() }), } diff --git a/src/services/api/filesApi.test.ts b/src/services/api/filesApi.test.ts new file mode 100644 index 00000000..87c6f925 --- /dev/null +++ b/src/services/api/filesApi.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { createSandboxedTestEnvironment } from '../../../scripts/pr/test-environment.js' +import type { FilesApiConfig } from './filesApi.js' + +type RecordedRequest = { + method: string + url: URL + headers: Headers + body: string +} + +const basePaths = [ + ['', ''], + ['/v1', ''], + ['/v1/', ''], + ['/anthropic', '/anthropic'], + ['/anthropic/v1/', '/anthropic'], + ['/v1/tenant', '/v1/tenant'], + ['/proxy/providers/provider-1', '/proxy/providers/provider-1'], +] as const + +async function withFilesApi( + run: (fixture: { + api: typeof import('./filesApi.js') + origin: string + requests: RecordedRequest[] + uploadPath: string + config: FilesApiConfig + }) => Promise, +): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'files-api-base-url-')) + const previousEnv = { ...process.env } + const env = createSandboxedTestEnvironment(tempDir) + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, env) + const requests: RecordedRequest[] = [] + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(request) { + const url = new URL(request.url) + requests.push({ method: request.method, url, headers: request.headers, body: await request.text() }) + if (request.method === 'POST') return Response.json({ id: 'file-uploaded' }, { status: 201 }) + if (url.pathname.endsWith('/content')) return new Response('downloaded content') + const secondPage = url.searchParams.has('after_id') + return Response.json({ + data: [{ id: secondPage ? 'file-2' : 'file-1', filename: secondPage ? 'two.txt' : 'one.txt', size_bytes: 7 }], + has_more: !secondPage, + }) + }, + }) + + try { + const uploadPath = path.join(tempDir, 'upload.txt') + await fs.writeFile(uploadPath, 'upload fixture content') + await run({ + api: await import('./filesApi.js'), + origin: server.url.origin, + requests, + uploadPath, + config: { oauthToken: 'fake-files-oauth-token', sessionId: 'test-files-session' }, + }) + } finally { + server.stop(true) + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, previousEnv) + await fs.rm(tempDir, { recursive: true, force: true }) + } +} + +function expectFilesAuth(requests: RecordedRequest[]): void { + for (const request of requests) { + expect(request.headers.get('authorization')).toBe('Bearer fake-files-oauth-token') + expect(request.headers.get('anthropic-version')).toBe('2023-06-01') + expect(request.headers.get('anthropic-beta')).toBe('files-api-2025-04-14,oauth-2025-04-20') + } +} + +describe('Files API base URL compatibility', () => { + test('downloads with one API version while preserving gateway prefixes and authentication', async () => { + await withFilesApi(async ({ api, origin, requests, config }) => { + for (const [basePath, prefix] of basePaths) { + requests.length = 0 + const content = await api.downloadFile('file-1', { ...config, baseUrl: `${origin}${basePath}` }) + + expect(content.toString()).toBe('downloaded content') + expect(requests.map(request => [request.method, request.url.href])).toEqual([ + ['GET', `${origin}${prefix}/v1/files/file-1/content`], + ]) + expectFilesAuth(requests) + } + }) + }) + + test('uploads with one API version and preserves the multipart payload', async () => { + await withFilesApi(async ({ api, origin, requests, config, uploadPath }) => { + for (const [basePath, prefix] of basePaths) { + requests.length = 0 + const result = await api.uploadFile(uploadPath, 'folder/report.txt', { ...config, baseUrl: `${origin}${basePath}` }) + + expect(result).toEqual({ path: 'folder/report.txt', fileId: 'file-uploaded', size: 22, success: true }) + expect(requests.map(request => [request.method, request.url.href])).toEqual([ + ['POST', `${origin}${prefix}/v1/files`], + ]) + expect(requests[0]!.headers.get('content-type')).toStartWith('multipart/form-data; boundary=') + expect(requests[0]!.body).toContain('filename="report.txt"') + expect(requests[0]!.body).toContain('upload fixture content') + expect(requests[0]!.body).toContain('name="purpose"\r\n\r\nuser_data') + expectFilesAuth(requests) + } + }) + }) + + test('lists every page with one API version and preserves filter and cursor parameters', async () => { + await withFilesApi(async ({ api, origin, requests, config }) => { + const afterCreatedAt = '2026-09-08T00:00:00Z' + for (const [basePath, prefix] of basePaths) { + requests.length = 0 + const files = await api.listFilesCreatedAfter(afterCreatedAt, { ...config, baseUrl: `${origin}${basePath}` }) + + expect(files).toEqual([ + { filename: 'one.txt', fileId: 'file-1', size: 7 }, + { filename: 'two.txt', fileId: 'file-2', size: 7 }, + ]) + expect(requests.map(request => [request.method, request.url.pathname])).toEqual([ + ['GET', `${prefix}/v1/files`], + ['GET', `${prefix}/v1/files`], + ]) + expect(requests.map(request => Object.fromEntries(request.url.searchParams))).toEqual([ + { after_created_at: afterCreatedAt }, + { after_created_at: afterCreatedAt, after_id: 'file-1' }, + ]) + expectFilesAuth(requests) + } + }) + }) + + test('normalizes environment fallback URLs for downloads, uploads, and listing', async () => { + await withFilesApi(async ({ api, origin, requests, config, uploadPath }) => { + for (const envKey of ['ANTHROPIC_BASE_URL', 'CLAUDE_CODE_API_BASE_URL']) { + delete process.env.ANTHROPIC_BASE_URL + delete process.env.CLAUDE_CODE_API_BASE_URL + process.env[envKey] = `${origin}/gateway/v1/` + requests.length = 0 + await api.downloadFile('file-1', config) + await api.uploadFile(uploadPath, 'report.txt', config) + await api.listFilesCreatedAfter('2026-09-08T00:00:00Z', config) + + expect(requests.map(request => request.url.pathname)).toEqual([ + '/gateway/v1/files/file-1/content', + '/gateway/v1/files', + '/gateway/v1/files', + '/gateway/v1/files', + ]) + expect(process.env[envKey]).toBe(`${origin}/gateway/v1/`) + expectFilesAuth(requests) + } + }) + }) +}) diff --git a/src/services/api/filesApi.ts b/src/services/api/filesApi.ts index cb9a03b9..b385b669 100644 --- a/src/services/api/filesApi.ts +++ b/src/services/api/filesApi.ts @@ -21,6 +21,7 @@ import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../analytics/index.js' +import { normalizeAnthropicBaseUrl } from './anthropicBaseUrl.js' // Files API is currently in beta. oauth-2025-04-20 enables Bearer OAuth // on public-api routes (auth.py: "oauth_auth" not in beta_versions → 404). @@ -133,7 +134,7 @@ export async function downloadFile( fileId: string, config: FilesApiConfig, ): Promise { - const baseUrl = config.baseUrl || getDefaultApiBaseUrl() + const baseUrl = normalizeAnthropicBaseUrl(config.baseUrl || getDefaultApiBaseUrl()) const url = `${baseUrl}/v1/files/${fileId}/content` const headers = { @@ -381,7 +382,7 @@ export async function uploadFile( config: FilesApiConfig, opts?: { signal?: AbortSignal }, ): Promise { - const baseUrl = config.baseUrl || getDefaultApiBaseUrl() + const baseUrl = normalizeAnthropicBaseUrl(config.baseUrl || getDefaultApiBaseUrl()) const url = `${baseUrl}/v1/files` const headers = { @@ -618,7 +619,7 @@ export async function listFilesCreatedAfter( afterCreatedAt: string, config: FilesApiConfig, ): Promise { - const baseUrl = config.baseUrl || getDefaultApiBaseUrl() + const baseUrl = normalizeAnthropicBaseUrl(config.baseUrl || getDefaultApiBaseUrl()) const headers = { Authorization: `Bearer ${config.oauthToken}`, 'anthropic-version': ANTHROPIC_VERSION,