mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
1076 lines
36 KiB
TypeScript
1076 lines
36 KiB
TypeScript
/**
|
|
* Integration tests for ToolSanitizationProxy
|
|
*
|
|
* Tests the full proxy flow: request sanitization → upstream forwarding → response restoration
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import * as http from 'http';
|
|
import { ToolSanitizationProxy } from '../../../src/cliproxy/tool-sanitization-proxy';
|
|
|
|
// Mock upstream server that echoes requests
|
|
let mockUpstream: http.Server;
|
|
let mockUpstreamPort: number;
|
|
let lastRequest: { body: unknown; headers: http.IncomingHttpHeaders } | null = null;
|
|
|
|
// Track response to send back
|
|
let mockResponse: { status: number; body: unknown; stream?: boolean } = {
|
|
status: 200,
|
|
body: { content: [] },
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
await new Promise<void>((resolve) => {
|
|
mockUpstream = http.createServer((req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
|
|
req.on('data', (chunk) => chunks.push(chunk));
|
|
req.on('end', () => {
|
|
const body = Buffer.concat(chunks).toString('utf8');
|
|
lastRequest = {
|
|
body: body ? JSON.parse(body) : null,
|
|
headers: req.headers,
|
|
};
|
|
|
|
if (mockResponse.stream) {
|
|
// SSE streaming response
|
|
res.writeHead(mockResponse.status, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive',
|
|
});
|
|
const events = mockResponse.body as string[];
|
|
for (const event of events) {
|
|
res.write(event);
|
|
}
|
|
res.end();
|
|
} else {
|
|
// Buffered JSON response
|
|
res.writeHead(mockResponse.status, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(mockResponse.body));
|
|
}
|
|
});
|
|
});
|
|
|
|
mockUpstream.listen(0, '127.0.0.1', () => {
|
|
const addr = mockUpstream.address();
|
|
mockUpstreamPort = typeof addr === 'object' && addr ? addr.port : 0;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
mockUpstream?.close();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
lastRequest = null;
|
|
mockResponse = { status: 200, body: { content: [] } };
|
|
});
|
|
|
|
describe('ToolSanitizationProxy Integration', () => {
|
|
describe('Proxy Lifecycle', () => {
|
|
it('starts on ephemeral port and stops cleanly', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
|
|
const port = await proxy.start();
|
|
expect(port).toBeGreaterThan(0);
|
|
expect(proxy.getPort()).toBe(port);
|
|
|
|
proxy.stop();
|
|
expect(proxy.getPort()).toBeNull();
|
|
});
|
|
|
|
it('returns same port on multiple start calls', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
|
|
const port1 = await proxy.start();
|
|
const port2 = await proxy.start();
|
|
expect(port1).toBe(port2);
|
|
|
|
proxy.stop();
|
|
});
|
|
});
|
|
|
|
describe('Request Sanitization', () => {
|
|
it('sanitizes duplicate segment tool names in request', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'test-model',
|
|
tools: [
|
|
{
|
|
name: 'gitmcp__plus-pro-components__plus-pro-components',
|
|
description: 'Test tool',
|
|
},
|
|
{ name: 'valid_tool', description: 'Valid tool' },
|
|
],
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(lastRequest).not.toBeNull();
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<{
|
|
name: string;
|
|
}>;
|
|
expect(sentTools[0].name).toBe('gitmcp__plus-pro-components');
|
|
expect(sentTools[1].name).toBe('valid_tool');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('truncates tool names exceeding 64 characters', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
const longName = 'a'.repeat(100);
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tools: [{ name: longName }],
|
|
}),
|
|
});
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<{
|
|
name: string;
|
|
}>;
|
|
expect(sentTools[0].name.length).toBe(64);
|
|
expect(sentTools[0].name).toContain('_'); // Has hash separator
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('passes through requests without tools unchanged', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
const originalBody = {
|
|
model: 'test-model',
|
|
messages: [{ role: 'user', content: 'Hello' }],
|
|
};
|
|
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(originalBody),
|
|
});
|
|
|
|
expect(lastRequest!.body).toEqual(originalBody);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'claude-sonnet-4.6-thinking',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(lastRequest).not.toBeNull();
|
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-sonnet-4-6');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('keeps dotted non-thinking model IDs unchanged', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'claude-sonnet-4.5',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(lastRequest).not.toBeNull();
|
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-sonnet-4.5');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('normalizes dotted Claude major.minor IDs on antigravity provider route', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'claude-opus-4.6',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(lastRequest).not.toBeNull();
|
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-opus-4-6');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('rejects denylisted AGY 4.5 model IDs on antigravity provider route', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'claude-sonnet-4.5',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error: string };
|
|
expect(body.error).toContain('denylist');
|
|
expect(lastRequest).toBeNull();
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('does not rewrite AGY legacy aliases on non-AGY provider routes', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/api/provider/claude/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'claude-sonnet-4-6-thinking',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(lastRequest).not.toBeNull();
|
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe(
|
|
'claude-sonnet-4-6-thinking'
|
|
);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('normalizes legacy iFlow aliases on explicit iflow provider routes', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/api/provider/iflow/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'kimi-k2.5',
|
|
messages: [{ role: 'user', content: 'test' }],
|
|
}),
|
|
});
|
|
|
|
expect(lastRequest).not.toBeNull();
|
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('kimi-k2');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('strips Gemini-unsupported top-level tool fields while keeping schema sanitization', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/api/provider/gemini/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'gemini-2.5-pro',
|
|
tools: [
|
|
{
|
|
name: 'tool__with__duplicate__duplicate',
|
|
description: 'Test description',
|
|
strict: true,
|
|
input_examples: [{ command: 'ls -la' }],
|
|
type: 'custom',
|
|
cache_control: { type: 'ephemeral' },
|
|
defer_loading: true,
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
command: {
|
|
type: 'string',
|
|
examples: ['ls -la'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
expect(sentTools[0].name).toBe('tool__with__duplicate');
|
|
expect(sentTools[0].description).toBe('Test description');
|
|
expect(sentTools[0].strict).toBeUndefined();
|
|
expect(sentTools[0].input_examples).toBeUndefined();
|
|
expect(sentTools[0].type).toBeUndefined();
|
|
expect(sentTools[0].cache_control).toBeUndefined();
|
|
expect(sentTools[0].defer_loading).toBeUndefined();
|
|
expect(sentTools[0].input_schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
command: {
|
|
type: 'string',
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('strips only Codex-unsupported top-level tool fields before forwarding', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/api/provider/codex/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'gpt-5.3-codex',
|
|
tools: [
|
|
{
|
|
name: 'codex_tool',
|
|
description: 'Codex test',
|
|
cache_control: { type: 'ephemeral' },
|
|
defer_loading: true,
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
examples: ['fix the failing test'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
expect(sentTools[0].name).toBe('codex_tool');
|
|
expect(sentTools[0].description).toBe('Codex test');
|
|
expect(sentTools[0].cache_control).toBeUndefined();
|
|
expect(sentTools[0].defer_loading).toBe(true);
|
|
expect(sentTools[0].input_schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
for (const model of [
|
|
'gpt-5.3-codex-xhigh',
|
|
'gpt-5.1-codex-mini',
|
|
'gpt-5.1-codex',
|
|
'gpt-5-codex',
|
|
]) {
|
|
it(`strips only Codex-unsupported top-level tool fields on root model-routed request (${model})`, async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
tools: [
|
|
{
|
|
name: 'root_codex_tool',
|
|
description: 'Root-routed Codex test',
|
|
cache_control: { type: 'ephemeral' },
|
|
defer_loading: true,
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
examples: ['fix the failing test'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
expect(sentTools[0].name).toBe('root_codex_tool');
|
|
expect(sentTools[0].description).toBe('Root-routed Codex test');
|
|
expect(sentTools[0].cache_control).toBeUndefined();
|
|
expect(sentTools[0].defer_loading).toBe(true);
|
|
expect(sentTools[0].input_schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
}
|
|
|
|
it('preserves top-level tool fields for non-target root routes', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'claude-sonnet-4-6',
|
|
tools: [
|
|
{
|
|
name: 'claude_tool__duplicate__duplicate',
|
|
description: 'Anthropic passthrough test',
|
|
cache_control: { type: 'ephemeral' },
|
|
defer_loading: true,
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
examples: ['keep these top-level fields'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
expect(sentTools[0].name).toBe('claude_tool__duplicate');
|
|
expect(sentTools[0].description).toBe('Anthropic passthrough test');
|
|
expect(sentTools[0].cache_control).toEqual({ type: 'ephemeral' });
|
|
expect(sentTools[0].defer_loading).toBe(true);
|
|
expect(sentTools[0].input_schema).toEqual({
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
},
|
|
},
|
|
});
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('preserves other tool properties during sanitization', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tools: [
|
|
{
|
|
name: 'foo__bar__bar',
|
|
description: 'Test description',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: { x: { type: 'string', examples: ['demo'] } },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<{
|
|
name: string;
|
|
description: string;
|
|
input_schema: object;
|
|
}>;
|
|
expect(sentTools[0].name).toBe('foo__bar');
|
|
expect(sentTools[0].description).toBe('Test description');
|
|
expect(sentTools[0].input_schema).toEqual({
|
|
type: 'object',
|
|
properties: { x: { type: 'string' } },
|
|
});
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Response Restoration (Buffered)', () => {
|
|
it('restores original tool names in tool_use response blocks', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
// Mock upstream returns sanitized name in tool_use
|
|
mockResponse = {
|
|
status: 200,
|
|
body: {
|
|
content: [
|
|
{ type: 'text', text: 'Using tool...' },
|
|
{ type: 'tool_use', id: 'call_123', name: 'foo__bar', input: { query: 'test' } },
|
|
],
|
|
},
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tools: [{ name: 'foo__bar__bar' }],
|
|
}),
|
|
});
|
|
|
|
const data = (await response.json()) as { content: Array<{ type: string; name?: string }> };
|
|
expect(data.content[0].type).toBe('text');
|
|
expect(data.content[1].type).toBe('tool_use');
|
|
expect(data.content[1].name).toBe('foo__bar__bar'); // Restored!
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('leaves unknown tool names unchanged in response', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
mockResponse = {
|
|
status: 200,
|
|
body: {
|
|
content: [{ type: 'tool_use', id: 'call_456', name: 'unknown_tool', input: {} }],
|
|
},
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tools: [{ name: 'foo__bar__bar' }],
|
|
}),
|
|
});
|
|
|
|
const data = (await response.json()) as { content: Array<{ name: string }> };
|
|
expect(data.content[0].name).toBe('unknown_tool');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Response Restoration (Streaming)', () => {
|
|
it('restores tool names in SSE streaming responses', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
// Mock SSE events with sanitized tool name
|
|
mockResponse = {
|
|
status: 200,
|
|
stream: true,
|
|
body: [
|
|
'event: content_block_start\n',
|
|
`data: ${JSON.stringify({ type: 'content_block_start', content_block: { type: 'tool_use', id: 'call_789', name: 'foo__bar' } })}\n\n`,
|
|
'event: content_block_stop\n',
|
|
'data: {"type":"content_block_stop"}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'foo__bar__bar' }],
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
expect(text).toContain('foo__bar__bar'); // Restored in SSE
|
|
expect(text).not.toContain('"name":"foo__bar"'); // Original sanitized name replaced
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('passes through SSE events when no sanitization occurred', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
mockResponse = {
|
|
status: 200,
|
|
stream: true,
|
|
body: [
|
|
'event: message_start\n',
|
|
'data: {"type":"message_start"}\n\n',
|
|
'event: content_block_delta\n',
|
|
'data: {"type":"content_block_delta","delta":{"text":"Hello"}}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'valid_tool' }], // No sanitization needed
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
expect(text).toContain('message_start');
|
|
expect(text).toContain('content_block_delta');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('returns 400 for invalid JSON request body', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: 'not valid json{',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const data = (await response.json()) as { error: string };
|
|
expect(data.error).toContain('Invalid JSON');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('forwards upstream errors correctly', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
mockResponse = {
|
|
status: 429,
|
|
body: { error: { type: 'rate_limit_error', message: 'Too many requests' } },
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tools: [] }),
|
|
});
|
|
|
|
expect(response.status).toBe(429);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('handles upstream connection errors gracefully', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: 'http://127.0.0.1:1', // Invalid port
|
|
timeoutMs: 1000,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ tools: [] }),
|
|
});
|
|
|
|
expect(response.status).toBe(502);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Non-JSON Passthrough', () => {
|
|
it('forwards GET requests without modification', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
mockResponse = { status: 200, body: { status: 'ok' } };
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
|
expect(response.ok).toBe(true);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Multiple Tools Sanitization', () => {
|
|
it('sanitizes multiple tools and tracks all mappings', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
mockResponse = {
|
|
status: 200,
|
|
body: {
|
|
content: [
|
|
{ type: 'tool_use', id: '1', name: 'tool_a__x', input: {} },
|
|
{ type: 'tool_use', id: '2', name: 'tool_b__y', input: {} },
|
|
],
|
|
},
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
tools: [{ name: 'tool_a__x__x' }, { name: 'tool_b__y__y' }, { name: 'tool_c_valid' }],
|
|
}),
|
|
});
|
|
|
|
// Verify request was sanitized
|
|
const sentTools = (lastRequest!.body as Record<string, unknown>).tools as Array<{
|
|
name: string;
|
|
}>;
|
|
expect(sentTools[0].name).toBe('tool_a__x');
|
|
expect(sentTools[1].name).toBe('tool_b__y');
|
|
expect(sentTools[2].name).toBe('tool_c_valid');
|
|
|
|
// Verify response was restored
|
|
const data = (await response.json()) as { content: Array<{ name: string }> };
|
|
expect(data.content[0].name).toBe('tool_a__x__x');
|
|
expect(data.content[1].name).toBe('tool_b__y__y');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Empty Response Safety Net (Issue #350)', () => {
|
|
it('injects synthetic response when streaming 200 has no content blocks', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
// Upstream sends message_start + message_delta + message_stop but NO content blocks
|
|
mockResponse = {
|
|
status: 200,
|
|
stream: true,
|
|
body: [
|
|
'event: message_start\n',
|
|
'data: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n',
|
|
'event: message_delta\n',
|
|
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":0}}\n\n',
|
|
'event: message_stop\n',
|
|
'data: {"type":"message_stop"}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'valid_tool' }],
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
// Should contain the synthetic error message
|
|
expect(text).toContain('[Proxy Error]');
|
|
expect(text).toContain('content_block_start');
|
|
expect(text).toContain('content_block_delta');
|
|
expect(text).toContain('content_block_stop');
|
|
// Should NOT have duplicate lifecycle events (upstream already sent them)
|
|
const messageStartCount = (text.match(/"type":"message_start"/g) || []).length;
|
|
expect(messageStartCount).toBe(1);
|
|
const messageDeltaCount = (text.match(/"type":"message_delta"/g) || []).length;
|
|
expect(messageDeltaCount).toBe(1);
|
|
const messageStopCount = (text.match(/"type":"message_stop"/g) || []).length;
|
|
expect(messageStopCount).toBe(1);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('does NOT inject synthetic response when content blocks exist', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
mockResponse = {
|
|
status: 200,
|
|
stream: true,
|
|
body: [
|
|
'event: message_start\n',
|
|
'data: {"type":"message_start","message":{"id":"msg_ok","type":"message","role":"assistant","content":[],"model":"test"}}\n\n',
|
|
'event: content_block_start\n',
|
|
'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n',
|
|
'event: content_block_delta\n',
|
|
'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}\n\n',
|
|
'event: content_block_stop\n',
|
|
'data: {"type":"content_block_stop","index":0}\n\n',
|
|
'event: message_delta\n',
|
|
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}\n\n',
|
|
'event: message_stop\n',
|
|
'data: {"type":"message_stop"}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'valid_tool' }],
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
expect(text).toContain('Hello');
|
|
expect(text).not.toContain('[Proxy Error]');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('does NOT inject synthetic response for 4xx/5xx errors', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
// 429 rate limit with SSE body but no content blocks
|
|
mockResponse = {
|
|
status: 429,
|
|
stream: true,
|
|
body: [
|
|
'event: error\n',
|
|
'data: {"type":"error","error":{"type":"rate_limit_error","message":"Too many requests"}}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'valid_tool' }],
|
|
}),
|
|
});
|
|
|
|
expect(response.status).toBe(429);
|
|
const text = await response.text();
|
|
expect(text).not.toContain('[Proxy Error]');
|
|
expect(text).toContain('rate_limit_error');
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('injects synthetic response via SSE processing path (with sanitized tool names)', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
// Upstream returns empty content — no content_block_start events
|
|
// Using sanitized tool name ('foo__bar__bar' → 'foo__bar') triggers the SSE processing path
|
|
mockResponse = {
|
|
status: 200,
|
|
stream: true,
|
|
body: [
|
|
'event: message_start\n',
|
|
'data: {"type":"message_start","message":{"id":"msg_sse","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n',
|
|
'event: message_delta\n',
|
|
'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":0}}\n\n',
|
|
'event: message_stop\n',
|
|
'data: {"type":"message_stop"}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'foo__bar__bar' }], // Triggers sanitization → SSE processing path
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
expect(text).toContain('[Proxy Error]');
|
|
expect(text).toContain('content_block_start');
|
|
// No duplicate lifecycle events
|
|
const messageStartCount = (text.match(/"type":"message_start"/g) || []).length;
|
|
expect(messageStartCount).toBe(1);
|
|
const messageDeltaCount = (text.match(/"type":"message_delta"/g) || []).length;
|
|
expect(messageDeltaCount).toBe(1);
|
|
const messageStopCount = (text.match(/"type":"message_stop"/g) || []).length;
|
|
expect(messageStopCount).toBe(1);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
|
|
it('injects synthetic response when upstream sends only message_start (real failure mode)', async () => {
|
|
const proxy = new ToolSanitizationProxy({
|
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
|
});
|
|
const port = await proxy.start();
|
|
|
|
// Real failure: upstream sends message_start then abruptly ends — no message_delta or message_stop
|
|
mockResponse = {
|
|
status: 200,
|
|
stream: true,
|
|
body: [
|
|
'event: message_start\n',
|
|
'data: {"type":"message_start","message":{"id":"msg_abrupt","type":"message","role":"assistant","content":[],"model":"test","stop_reason":null}}\n\n',
|
|
],
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stream: true,
|
|
tools: [{ name: 'valid_tool' }],
|
|
}),
|
|
});
|
|
|
|
const text = await response.text();
|
|
expect(text).toContain('[Proxy Error]');
|
|
expect(text).toContain('content_block_start');
|
|
expect(text).toContain('message_delta');
|
|
expect(text).toContain('message_stop');
|
|
// Only 1 of each — upstream sent message_start, synthetic provides the rest
|
|
const messageStartCount = (text.match(/"type":"message_start"/g) || []).length;
|
|
expect(messageStartCount).toBe(1);
|
|
const messageDeltaCount = (text.match(/"type":"message_delta"/g) || []).length;
|
|
expect(messageDeltaCount).toBe(1);
|
|
const messageStopCount = (text.match(/"type":"message_stop"/g) || []).length;
|
|
expect(messageStopCount).toBe(1);
|
|
} finally {
|
|
proxy.stop();
|
|
}
|
|
});
|
|
});
|
|
});
|