Files
evomap__evolver/test/proxyBedrock.test.js
evolver-publish 8e2e91d456 Release v1.89.18
2026-06-28 22:47:18 +08:00

687 lines
30 KiB
JavaScript

'use strict';
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { EvoMapProxy } = require('../src/proxy');
// Mock the @aws-sdk/client-bedrock-runtime surface that _proxyBedrock
// uses. We inject this via proxy._bedrockSdk so the real SDK never
// loads — keeps the test hermetic and avoids constructing AWS
// event-stream binary fixtures (which require internal smithy modules
// that are deep transitive deps).
function makeMockSdk(handlers) {
let lastClientArgs = null;
let lastCommand = null;
class MockBedrockRuntimeClient {
constructor(args) { lastClientArgs = args; }
async send(command) {
lastCommand = command;
const isStream = command instanceof MockInvokeModelWithResponseStreamCommand;
return isStream
? handlers.stream(command.input)
: handlers.invoke(command.input);
}
}
class MockInvokeModelCommand { constructor(input) { this.input = input; } }
class MockInvokeModelWithResponseStreamCommand { constructor(input) { this.input = input; } }
return {
sdk: {
BedrockRuntimeClient: MockBedrockRuntimeClient,
InvokeModelCommand: MockInvokeModelCommand,
InvokeModelWithResponseStreamCommand: MockInvokeModelWithResponseStreamCommand,
},
inspect: () => ({ lastClientArgs, lastCommand }),
};
}
async function readSseStream(stream) {
const reader = stream.getReader();
const dec = new TextDecoder();
let out = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
out += dec.decode(value);
}
return out;
}
describe('EvoMapProxy._proxyBedrock', () => {
let proxy;
before(() => {
proxy = new EvoMapProxy({
logger: { log: () => {}, error: () => {}, warn: () => {} },
});
});
it('non-stream: rewrites body, strips model, injects anthropic_version, returns Anthropic-shaped JSON', async () => {
const mock = makeMockSdk({
invoke: () => ({
body: Buffer.from(JSON.stringify({ id: 'msg_1', content: [{ type: 'text', text: 'hi' }] })),
}),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 16,
}, {
bedrockRegion: 'us-east-1',
bedrockCredentials: { accessKeyId: 'test', secretAccessKey: 'test' },
});
assert.equal(result.status, 200);
assert.equal(result.headers['content-type'], 'application/json');
assert.equal(result.stream, null);
const json = result.json();
assert.equal(json.id, 'msg_1');
assert.equal(json.content[0].text, 'hi');
const { lastCommand } = mock.inspect();
assert.equal(lastCommand.input.modelId, 'anthropic.claude-3-5-haiku-20241022-v1:0');
const sentBody = JSON.parse(lastCommand.input.body);
assert.equal(sentBody.model, undefined, 'top-level model must be stripped (Bedrock 400s on unknown fields)');
assert.equal(sentBody.anthropic_version, 'bedrock-2023-05-31');
assert.deepEqual(sentBody.messages, [{ role: 'user', content: 'hi' }]);
assert.equal(sentBody.max_tokens, 16);
});
it('normalizes Claude Code bare model aliases before sending to Bedrock', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'claude-haiku-4-5-20251001',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 16,
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
assert.equal(
mock.inspect().lastCommand.input.modelId,
'global.anthropic.claude-haiku-4-5-20251001-v1:0'
);
});
it('non-stream: missing body.model returns 400 Anthropic-shaped error', async () => {
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw new Error('should not be called'); },
stream: () => { throw new Error('should not be called'); },
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
messages: [{ role: 'user', content: 'hi' }],
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
assert.equal(result.status, 400);
const json = result.json();
assert.equal(json.type, 'error');
assert.match(json.error.message, /model required/i);
});
it('streaming: re-emits Bedrock chunks as standard SSE data: ... \\n\\n', async () => {
const chunks = [
{ type: 'message_start', message: { id: 'msg_1' } },
{ type: 'content_block_delta', delta: { type: 'text_delta', text: 'hi' } },
{ type: 'message_stop' },
];
async function* fakeStream() {
for (const c of chunks) {
yield { chunk: { bytes: Buffer.from(JSON.stringify(c)) } };
}
}
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw new Error('should not be called'); },
stream: () => ({ body: fakeStream() }),
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
assert.equal(result.status, 200);
assert.equal(result.headers['content-type'], 'text/event-stream');
assert.notEqual(result.stream, null);
const sse = await readSseStream(result.stream);
// Each chunk must become exactly one `data: <json>\n\n` frame.
assert.match(sse, /^data: \{"type":"message_start"/m);
assert.match(sse, /data: \{"type":"content_block_delta".*"text":"hi"/);
assert.match(sse, /data: \{"type":"message_stop"\}\n\n$/);
// Three chunks -> three frames -> three `\n\n` separators.
const frameSeparators = sse.split('\n\n').length - 1;
assert.equal(frameSeparators, 3);
});
it('streaming: error events become Anthropic-shaped error frames', async () => {
async function* fakeStream() {
yield { chunk: { bytes: Buffer.from(JSON.stringify({ type: 'message_start' })) } };
yield { throttlingException: { name: 'ThrottlingException', message: 'slow down' } };
}
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw new Error('should not be called'); },
stream: () => ({ body: fakeStream() }),
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sse = await readSseStream(result.stream);
assert.match(sse, /event: error\ndata: \{"type":"error".*"ThrottlingException"/);
});
// Bedrock InvokeModelWithResponseStream documents six exception envelope
// shapes; missing one silently drops the error (stream closes clean,
// client sees a truncated response with no signal). These two had been
// missed in the initial cut.
for (const exKey of ['modelTimeoutException', 'serviceUnavailableException']) {
it(`streaming: ${exKey} also surfaces as Anthropic-shaped error frame`, async () => {
const exName = exKey === 'modelTimeoutException' ? 'ModelTimeoutException' : 'ServiceUnavailableException';
async function* fakeStream() {
yield { chunk: { bytes: Buffer.from(JSON.stringify({ type: 'message_start' })) } };
yield { [exKey]: { name: exName, message: 'upstream went sideways' } };
}
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw new Error('should not be called'); },
stream: () => ({ body: fakeStream() }),
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sse = await readSseStream(result.stream);
assert.match(sse, new RegExp(`event: error\\ndata: \\{"type":"error".*"${exName}"`));
});
}
it('error relay: SDK ServiceException becomes Anthropic-shaped error envelope with httpStatusCode', async () => {
const err = Object.assign(new Error('access denied'), {
name: 'AccessDeniedException',
$metadata: { httpStatusCode: 403 },
});
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw err; },
stream: () => { throw new Error('should not be called'); },
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
assert.equal(result.status, 403);
const json = result.json();
assert.equal(json.type, 'error');
assert.equal(json.error.type, 'AccessDeniedException');
assert.match(json.error.message, /access denied/);
});
it('error with no $metadata defaults to status 500', async () => {
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw new Error('boom'); },
stream: () => { throw new Error('should not be called'); },
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
assert.equal(result.status, 500);
});
it('us.* dated Bedrock IDs canonicalize to the global.* equivalent at proxy boundary', async () => {
// PR #135 made `canonicalizeForBedrock` rewrite `us.*` → `global.*` so a
// client carrying a regional inference profile doesn't end up pinned to
// it. That canonicalize is now applied at the proxy boundary as well so
// the rewrite is consistent whether or not the router-decision step is
// enabled (defense-in-depth for EVOMAP_ROUTER_ENABLED=0 deployments).
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from(JSON.stringify({ ok: true })) }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
messages: [{ role: 'user', content: 'hi' }],
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const { lastCommand } = mock.inspect();
assert.equal(lastCommand.input.modelId, 'global.anthropic.claude-haiku-4-5-20251001-v1:0');
});
it('client constructor receives region + endpoint + credentials from opts', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('nope'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [],
}, {
bedrockRegion: 'us-west-2',
bedrockEndpoint: 'http://127.0.0.1:9999',
bedrockCredentials: { accessKeyId: 'AK', secretAccessKey: 'SK' },
});
const { lastClientArgs } = mock.inspect();
assert.equal(lastClientArgs.region, 'us-west-2');
assert.equal(lastClientArgs.endpoint, 'http://127.0.0.1:9999');
assert.deepEqual(lastClientArgs.credentials, { accessKeyId: 'AK', secretAccessKey: 'SK' });
});
it('streaming: cancel() on the ReadableStream closes the upstream AsyncIterable', async () => {
let returnCalled = false;
const fakeStream = {
[Symbol.asyncIterator]() {
return {
async next() {
return { value: { chunk: { bytes: Buffer.from(JSON.stringify({ type: 'message_start' })) } }, done: false };
},
async return() { returnCalled = true; return { value: undefined, done: true }; },
};
},
};
proxy._bedrockSdk = makeMockSdk({
invoke: () => { throw new Error('should not be called'); },
stream: () => ({ body: fakeStream }),
}).sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const reader = result.stream.getReader();
await reader.read();
await reader.cancel();
// Give the cancel hook a tick to run.
await new Promise(resolve => setImmediate(resolve));
assert.equal(returnCalled, true, 'cancel() must invoke AsyncIterator.return() to release the upstream');
});
it('reuses BedrockRuntimeClient across calls with the same constructor args', async () => {
const constructed = [];
function makeMockSdkInstrumented() {
class MockBedrockRuntimeClient {
constructor(args) { constructed.push(args); }
async send() { return { body: Buffer.from('{}') }; }
}
class MockInvokeModelCommand { constructor(input) { this.input = input; } }
class MockInvokeModelWithResponseStreamCommand { constructor(input) { this.input = input; } }
return {
BedrockRuntimeClient: MockBedrockRuntimeClient,
InvokeModelCommand: MockInvokeModelCommand,
InvokeModelWithResponseStreamCommand: MockInvokeModelWithResponseStreamCommand,
};
}
proxy._bedrockSdk = makeMockSdkInstrumented();
proxy._bedrockClient = null;
proxy._bedrockClientKey = null;
proxy._bedrockClientSdk = null;
const opts = { bedrockRegion: 'us-east-1', bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } };
const body = { model: 'anthropic.claude-3-5-haiku-20241022-v1:0', messages: [] };
await proxy._proxyBedrock('/v1/messages', body, opts);
await proxy._proxyBedrock('/v1/messages', body, opts);
await proxy._proxyBedrock('/v1/messages', body, opts);
assert.equal(constructed.length, 1, 'client must be reused across same-args calls');
// Different region → new client.
await proxy._proxyBedrock('/v1/messages', body, { ...opts, bedrockRegion: 'us-west-2' });
assert.equal(constructed.length, 2, 'changing region must rebuild the client');
});
// Claude Code v2.1.150+ sends thinking:{type:'adaptive'} for Opus 4.7+;
// Bedrock InvokeModel on the 4.5/4.1-gen profiles 400s on it. Normalize.
it('thinking: type="adaptive" with explicit budget_tokens is folded to "enabled" preserving budget', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
max_tokens: 8192,
messages: [{ role: 'user', content: 'hi' }],
thinking: { type: 'adaptive', budget_tokens: 4096 },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.equal(sent.thinking.type, 'enabled');
assert.equal(sent.thinking.budget_tokens, 4096, 'explicit budget_tokens preserved');
});
// CC omits budget_tokens in adaptive mode (model decides). Bedrock 'enabled'
// requires it, so we default to max_tokens/2.
it('thinking: type="adaptive" without budget_tokens defaults to max_tokens/2', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
max_tokens: 8192,
messages: [{ role: 'user', content: 'hi' }],
thinking: { type: 'adaptive' },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.equal(sent.thinking.type, 'enabled');
assert.equal(sent.thinking.budget_tokens, 4096, 'should default to max_tokens/2');
});
// Anthropic: budget_tokens >= 1024. Bedrock: budget_tokens < max_tokens.
// For max_tokens <= 1024 the constraints are incompatible — the only
// valid action is to drop thinking entirely.
it('thinking: adaptive with max_tokens <= 1024 folds to disabled (constraints incompatible)', async () => {
for (const maxTokens of [256, 512, 1024]) {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
proxy._bedrockClient = null;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
max_tokens: maxTokens,
messages: [{ role: 'user', content: 'hi' }],
thinking: { type: 'adaptive' },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.deepEqual(sent.thinking, { type: 'disabled' }, `max_tokens=${maxTokens} should disable thinking`);
}
});
// Above 1024 the 1024 floor is safe (1024 < max_tokens).
it('thinking: adaptive with max_tokens just above 1024 keeps 1024 floor', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
max_tokens: 2000,
messages: [{ role: 'user', content: 'hi' }],
thinking: { type: 'adaptive' },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.equal(sent.thinking.type, 'enabled');
assert.equal(sent.thinking.budget_tokens, 1024, 'floor 1024 < max_tokens 2000 is valid');
});
// Caller's explicit budget_tokens is honored even when max_tokens is small —
// they've taken responsibility for the relationship; we don't second-guess.
it('thinking: adaptive with explicit small budget_tokens passes through even at small max_tokens', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
max_tokens: 1024,
messages: [{ role: 'user', content: 'hi' }],
thinking: { type: 'adaptive', budget_tokens: 800 },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.equal(sent.thinking.type, 'enabled');
assert.equal(sent.thinking.budget_tokens, 800);
});
it('thinking: adaptive and output_config pass through for Opus 4.7', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'global.anthropic.claude-opus-4-7',
max_tokens: 8192,
messages: [{ role: 'user', content: 'compact this session' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
context_management: { edits: [] },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.deepEqual(sent.thinking, { type: 'adaptive' });
assert.deepEqual(sent.output_config, { effort: 'xhigh' });
assert.equal('context_management' in sent, false, 'context_management must still be stripped');
});
it('thinking: adaptive and output_config pass through for Opus 4.8', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'global.anthropic.claude-opus-4-8',
max_tokens: 8192,
messages: [{ role: 'user', content: 'compact this session' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
context_management: { edits: [] },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.deepEqual(sent.thinking, { type: 'adaptive' });
assert.deepEqual(sent.output_config, { effort: 'xhigh' });
assert.equal('context_management' in sent, false, 'context_management must still be stripped');
});
// CC v2.1.150+ adds top-level fields Bedrock InvokeModel doesn't accept;
// unknown top-level keys cause it to 400 the whole call.
it('strips CC-specific top-level fields (output_config, context_management) before forwarding to Bedrock', async () => {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
messages: [{ role: 'user', content: 'hi' }],
output_config: { effort: 'xhigh' },
context_management: { edits: [] },
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.equal('output_config' in sent, false, 'output_config must not be forwarded');
assert.equal('context_management' in sent, false, 'context_management must not be forwarded');
});
it('thinking: "enabled" / "disabled" / undefined pass through untouched', async () => {
for (const value of [{ type: 'enabled', budget_tokens: 1024 }, { type: 'disabled' }, undefined]) {
const mock = makeMockSdk({
invoke: () => ({ body: Buffer.from('{}') }),
stream: () => { throw new Error('should not be called'); },
});
proxy._bedrockSdk = mock.sdk;
proxy._bedrockClient = null; // force rebuild so mock.inspect tracks this call
await proxy._proxyBedrock('/v1/messages', {
model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
messages: [{ role: 'user', content: 'hi' }],
...(value ? { thinking: value } : {}),
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
const sent = JSON.parse(mock.inspect().lastCommand.input.body);
assert.deepEqual(sent.thinking, value);
}
});
it('strips body.stream before forwarding (Bedrock infers from command type)', async () => {
const mock = makeMockSdk({
invoke: () => { throw new Error('should not be called'); },
stream: () => ({ body: (async function*(){ yield { chunk: { bytes: Buffer.from('{}') } }; })() }),
});
proxy._bedrockSdk = mock.sdk;
const result = await proxy._proxyBedrock('/v1/messages', {
model: 'anthropic.claude-3-5-haiku-20241022-v1:0',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}, { bedrockCredentials: { accessKeyId: 't', secretAccessKey: 't' } });
// drain so the async generator is awaited
await readSseStream(result.stream);
const { lastCommand } = mock.inspect();
const sent = JSON.parse(lastCommand.input.body);
assert.equal(sent.stream, undefined, 'body.stream must be removed before forwarding');
});
});
describe('EvoMapProxy provider dispatch via opts.upstreamMode', () => {
const { buildMessagesHandler } = require('../src/proxy/router/messages_route');
// Dispatch reads opts.upstreamMode (set by messages_route.js once per
// request), NOT process.env directly — that's the whole point of the
// single-source-of-truth fix. Both tests here exercise the dispatch
// closure with opts.upstreamMode rather than env, mirroring how the
// closure is actually called in production.
function makeDispatch(proxy) {
return (reqPath, body, opts) => {
const mode = opts?.upstreamMode || 'anthropic';
return mode === 'bedrock'
? proxy._proxyBedrock(reqPath, body, opts)
: proxy._proxyAnthropic(reqPath, body, opts);
};
}
it('opts.upstreamMode unset → _proxyAnthropic', async () => {
const proxy = new EvoMapProxy({ logger: { log: () => {}, error: () => {}, warn: () => {} } });
let anthropicCalled = false;
let bedrockCalled = false;
proxy._proxyAnthropic = async () => { anthropicCalled = true; return { status: 200, headers: {}, stream: null, json: () => ({}), text: () => '{}' }; };
proxy._proxyBedrock = async () => { bedrockCalled = true; return { status: 200, headers: {}, stream: null, json: () => ({}), text: () => '{}' }; };
await makeDispatch(proxy)('/v1/messages', {}, {});
assert.equal(anthropicCalled, true);
assert.equal(bedrockCalled, false);
});
it('opts.upstreamMode="bedrock" → _proxyBedrock', async () => {
const proxy = new EvoMapProxy({ logger: { log: () => {}, error: () => {}, warn: () => {} } });
let anthropicCalled = false;
let bedrockCalled = false;
proxy._proxyAnthropic = async () => { anthropicCalled = true; return { status: 200, headers: {}, stream: null, json: () => ({}), text: () => '{}' }; };
proxy._proxyBedrock = async () => { bedrockCalled = true; return { status: 200, headers: {}, stream: null, json: () => ({}), text: () => '{}' }; };
await makeDispatch(proxy)('/v1/messages', {}, { upstreamMode: 'bedrock' });
assert.equal(bedrockCalled, true);
assert.equal(anthropicCalled, false);
});
it('messages_route.js threads upstreamMode through to anthropicProxy', async () => {
// End-to-end: hand the handler a mock anthropicProxy and verify that
// when EVOMAP_UPSTREAM=bedrock the gate is skipped AND the call reaches
// anthropicProxy with opts.upstreamMode='bedrock' (one env read, two
// consumers). This is the regression guard for the TOCTOU bug.
const prev = process.env.EVOMAP_UPSTREAM;
process.env.EVOMAP_UPSTREAM = 'bedrock';
try {
let receivedOpts = null;
const handler = buildMessagesHandler({
anthropicProxy: async (_p, _b, opts) => {
receivedOpts = opts;
return { status: 200, headers: {}, stream: null, json: () => ({}), text: () => '{}' };
},
logger: { log: () => {}, warn: () => {}, error: () => {} },
routerEnabled: false,
});
await handler({ body: { messages: [] }, headers: {} });
assert.equal(receivedOpts.upstreamMode, 'bedrock');
} finally {
if (prev === undefined) delete process.env.EVOMAP_UPSTREAM;
else process.env.EVOMAP_UPSTREAM = prev;
}
});
});
describe('messages_route auth gate softens under EVOMAP_UPSTREAM=bedrock', () => {
const { buildMessagesHandler } = require('../src/proxy/router/messages_route');
it('throws 401 when neither inbound x-api-key nor proxy env creds (default mode)', async () => {
const prevMode = process.env.EVOMAP_UPSTREAM;
const prevKey = process.env.ANTHROPIC_API_KEY;
const prevUpstreamKey = process.env.EVOMAP_ANTHROPIC_API_KEY;
const prevTok = process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.EVOMAP_UPSTREAM;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.EVOMAP_ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
try {
const handler = buildMessagesHandler({
anthropicProxy: async () => { throw new Error('should not reach upstream'); },
logger: { log: () => {}, warn: () => {}, error: () => {} },
routerEnabled: false,
});
await assert.rejects(
() => handler({ body: { messages: [] }, headers: {} }),
(err) => err.statusCode === 401 && /x-api-key required/.test(err.message),
);
} finally {
if (prevMode !== undefined) process.env.EVOMAP_UPSTREAM = prevMode;
if (prevKey !== undefined) process.env.ANTHROPIC_API_KEY = prevKey;
if (prevUpstreamKey !== undefined) process.env.EVOMAP_ANTHROPIC_API_KEY = prevUpstreamKey;
if (prevTok !== undefined) process.env.ANTHROPIC_AUTH_TOKEN = prevTok;
}
});
it('skips the credential check when EVOMAP_UPSTREAM=bedrock (proxy_token gate is upstream of this handler)', async () => {
const prevMode = process.env.EVOMAP_UPSTREAM;
const prevKey = process.env.ANTHROPIC_API_KEY;
const prevUpstreamKey = process.env.EVOMAP_ANTHROPIC_API_KEY;
const prevTok = process.env.ANTHROPIC_AUTH_TOKEN;
process.env.EVOMAP_UPSTREAM = 'bedrock';
delete process.env.ANTHROPIC_API_KEY;
delete process.env.EVOMAP_ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_AUTH_TOKEN;
try {
let called = false;
const handler = buildMessagesHandler({
anthropicProxy: async () => {
called = true;
return { status: 200, headers: {}, stream: null, json: () => ({ ok: true }), text: () => '{"ok":true}' };
},
logger: { log: () => {}, warn: () => {}, error: () => {} },
routerEnabled: false,
});
const result = await handler({ body: { messages: [] }, headers: {} });
assert.equal(called, true, 'handler must reach upstream without 401');
assert.equal(result.status, 200);
} finally {
if (prevMode === undefined) delete process.env.EVOMAP_UPSTREAM;
else process.env.EVOMAP_UPSTREAM = prevMode;
if (prevKey !== undefined) process.env.ANTHROPIC_API_KEY = prevKey;
if (prevUpstreamKey !== undefined) process.env.EVOMAP_ANTHROPIC_API_KEY = prevUpstreamKey;
if (prevTok !== undefined) process.env.ANTHROPIC_AUTH_TOKEN = prevTok;
}
});
});