mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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-'))
|
||||
|
||||
@@ -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() }),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user