fix(anthropic): avoid duplicate /v1 in compatible endpoints (#1279)

Normalize a terminal API version at Anthropic request boundaries while
preserving gateway prefixes and saved provider settings. Cover SDK messages,
token counting, proxy streams, connectivity, titles, files, and recovery CLI.

Fixes #1279
This commit is contained in:
程序员阿江(Relakkes)
2026-09-08 13:46:51 +08:00
parent 7d13cc6d3c
commit f0acdc4ce0
13 changed files with 563 additions and 25 deletions
+146
View File
@@ -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<typeof Bun.spawn> | 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 })
}
})
}
}
})
+27 -16
View File
@@ -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<string> {
if (process.stdin.isTTY) return ''
async function readPromptFromStdin(input: RecoveryInput): Promise<string> {
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<void> {
const parsed = parseArgs(process.argv.slice(2))
export async function run(
argv = process.argv.slice(2),
input: RecoveryInput = process.stdin,
): Promise<void> {
const parsed = parseArgs(argv)
if (parsed.command === 'help') {
printHelp()
@@ -137,11 +142,11 @@ async function run(): Promise<void> {
}
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<void> {
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<void> {
}, input: RecoveryInput): Promise<void> {
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
})
}
+70
View File
@@ -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<string, string> }> = []
@@ -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<string, unknown> | null = null
const server = Bun.serve({
+2 -1
View File
@@ -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<Response> {
const transformed = hoistToolResultMediaForCompatibility(body)
const url = `${baseUrl}/v1/messages`
const url = `${normalizeAnthropicBaseUrl(baseUrl)}/v1/messages`
const proxyOptions = getNetworkProxyFetchOptions(networkSettings, url)
const headers: Record<string, string> = {
+3 -2
View File
@@ -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',
+2 -1
View File
@@ -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 = {
+24
View File
@@ -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)
})
})
+23
View File
@@ -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
}
}
+69
View File
@@ -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-'))
+3 -2
View File
@@ -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() }),
}
+163
View File
@@ -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<void>,
): Promise<void> {
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)
}
})
})
})
+4 -3
View File
@@ -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<Buffer> {
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<UploadResult> {
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<FileMetadata[]> {
const baseUrl = config.baseUrl || getDefaultApiBaseUrl()
const baseUrl = normalizeAnthropicBaseUrl(config.baseUrl || getDefaultApiBaseUrl())
const headers = {
Authorization: `Bearer ${config.oauthToken}`,
'anthropic-version': ANTHROPIC_VERSION,