mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
687 lines
30 KiB
JavaScript
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;
|
|
}
|
|
});
|
|
});
|