feat: implement protocol parity and harden upstream handling (#88)
* feat: implement protocol parity and harden upstream handling * docs(agents): add lodash-es utility method and named import guideline
This commit is contained in:
23 files changed
+4408
-654
No files matched your search
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { GeminiController } from '../../server/modules/proxy/gemini.controller';
|
||||
|
||||
function createReplyMock() {
|
||||
const reply: Record<string, any> = {};
|
||||
reply.status = vi.fn(() => reply);
|
||||
reply.header = vi.fn(() => reply);
|
||||
reply.send = vi.fn(() => reply);
|
||||
return reply;
|
||||
}
|
||||
|
||||
describe('GeminiController Integration', () => {
|
||||
it('supports list and get model endpoints', () => {
|
||||
const proxyService = {};
|
||||
const controller = new GeminiController(proxyService as any);
|
||||
const replyList = createReplyMock();
|
||||
const replyGet = createReplyMock();
|
||||
|
||||
controller.listModels(replyList as any);
|
||||
controller.getModel('gemini-2.5-flash', replyGet as any);
|
||||
|
||||
expect(replyList.status).toHaveBeenCalledWith(200);
|
||||
expect(replyList.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
models: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
expect(replyList.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'models/gemini-2.5-flash',
|
||||
description: '',
|
||||
inputTokenLimit: 128000,
|
||||
outputTokenLimit: 8192,
|
||||
supportedGenerationMethods: ['generateContent', 'countTokens'],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(replyGet.status).toHaveBeenCalledWith(200);
|
||||
expect(replyGet.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'models/gemini-2.5-flash',
|
||||
displayName: 'gemini-2.5-flash',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles generateContent action from colon endpoint format', async () => {
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'hello' }] },
|
||||
finishReason: 'STOP',
|
||||
avgLogprobs: -0.1,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 1,
|
||||
totalTokenCount: 2,
|
||||
},
|
||||
createTime: '2026-02-10T10:00:00.000Z',
|
||||
modelVersion: 'gemini-2.5-flash-latest',
|
||||
responseId: 'resp_123',
|
||||
}),
|
||||
handleGeminiStreamGenerateContent: vi.fn(),
|
||||
};
|
||||
const controller = new GeminiController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.modelAction(
|
||||
'models/gemini-2.5-flash:generateContent',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledWith(
|
||||
'models/gemini-2.5-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'hello' }] },
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 1,
|
||||
totalTokenCount: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('handles streamGenerateContent action and emits SSE headers', async () => {
|
||||
const stream = of('data: {"ok":true}\n\n');
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn(),
|
||||
handleGeminiStreamGenerateContent: vi.fn().mockResolvedValue(stream),
|
||||
};
|
||||
const controller = new GeminiController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.modelAction(
|
||||
'gemini-2.5-flash:streamGenerateContent',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream');
|
||||
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
||||
expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive');
|
||||
expect(reply.send).toHaveBeenCalledWith(stream);
|
||||
});
|
||||
|
||||
it('supports countTokens action', async () => {
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn(),
|
||||
handleGeminiStreamGenerateContent: vi.fn(),
|
||||
};
|
||||
const controller = new GeminiController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.countTokens(
|
||||
'gemini-2.5-flash',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'abcd efgh' }] }] } as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith({ totalTokens: 0 });
|
||||
});
|
||||
|
||||
it('returns bad request for invalid combined endpoint action', async () => {
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn(),
|
||||
handleGeminiStreamGenerateContent: vi.fn(),
|
||||
};
|
||||
const controller = new GeminiController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.modelAction(
|
||||
'models/gemini-2.5-flash-generateContent',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] } as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(400);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
status: 'INVALID_ARGUMENT',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -12,6 +11,10 @@ vi.mock('../../utils/paths', async () => {
|
||||
|
||||
describe('Logger Utilities', () => {
|
||||
const testLogDir = path.join(process.cwd(), 'temp_test_logs');
|
||||
let logger: {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
const getLatestLogFile = () => {
|
||||
const files = fs
|
||||
@@ -27,7 +30,7 @@ describe('Logger Utilities', () => {
|
||||
};
|
||||
|
||||
const waitForLogContains = async (text: string) => {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const filePath = getLatestLogFile();
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -41,30 +44,22 @@ describe('Logger Utilities', () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
if (fs.existsSync(testLogDir)) {
|
||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(testLogDir, { recursive: true });
|
||||
const loggerModule = await import('../../utils/logger');
|
||||
logger = loggerModule.logger;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
if (fs.existsSync(testLogDir)) {
|
||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(testLogDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error('beforeEach: setup testLogDir failed', err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
try {
|
||||
if (fs.existsSync(testLogDir)) {
|
||||
fs.rmSync(testLogDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('afterEach: cleanup testLogDir failed', err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { ProxyController } from '../../server/modules/proxy/proxy.controller';
|
||||
|
||||
function createReplyMock() {
|
||||
const reply: Record<string, any> = {};
|
||||
reply.status = vi.fn(() => reply);
|
||||
reply.header = vi.fn(() => reply);
|
||||
reply.send = vi.fn(() => reply);
|
||||
return reply;
|
||||
}
|
||||
|
||||
describe('ProxyController Integration', () => {
|
||||
it('supports models listing endpoints', () => {
|
||||
const proxyService = {};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
|
||||
const allModels = controller.getModels();
|
||||
const claudeModels = controller.getClaudeModels();
|
||||
|
||||
expect(allModels.object).toBe('list');
|
||||
expect(Array.isArray(allModels.data)).toBe(true);
|
||||
expect(allModels.data.length).toBeGreaterThan(0);
|
||||
expect(allModels.data.every((model) => model.owned_by === 'antigravity')).toBe(true);
|
||||
expect(allModels.data.every((model) => model.created === 1770652800)).toBe(true);
|
||||
|
||||
expect(claudeModels).toEqual(allModels);
|
||||
});
|
||||
|
||||
it('routes Claude OpenAI requests to protocol parity path', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({ ok: true }),
|
||||
handleAnthropicMessages: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.chatCompletions(
|
||||
{
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleChatCompletions).toHaveBeenCalledOnce();
|
||||
expect(proxyService.handleAnthropicMessages).not.toHaveBeenCalled();
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('returns stream response with SSE headers for parity stream path', async () => {
|
||||
const stream = of('data: {"ok":true}\n\n');
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue(stream),
|
||||
handleAnthropicMessages: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.chatCompletions(
|
||||
{
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledWith('Content-Type', 'text/event-stream');
|
||||
expect(reply.header).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
||||
expect(reply.header).toHaveBeenCalledWith('Connection', 'keep-alive');
|
||||
expect(reply.send).toHaveBeenCalledWith(stream);
|
||||
});
|
||||
|
||||
it('supports OpenAI completions compatibility endpoint', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||
id: 'chatcmpl_test',
|
||||
object: 'chat.completion',
|
||||
created: 1700000000,
|
||||
model: 'gpt-4o',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'hello from assistant',
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}),
|
||||
handleAnthropicMessages: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.completions(
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
prompt: 'hello world',
|
||||
stream: false,
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleChatCompletions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'gpt-4o',
|
||||
messages: [{ role: 'user', content: 'hello world' }],
|
||||
}),
|
||||
);
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
object: 'text_completion',
|
||||
model: 'gpt-4o',
|
||||
choices: [
|
||||
expect.objectContaining({
|
||||
text: 'hello from assistant',
|
||||
logprobs: null,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('supports OpenAI responses compatibility endpoint with normalized input', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||
id: 'chatcmpl_resp',
|
||||
object: 'chat.completion',
|
||||
created: 1700000001,
|
||||
model: 'gpt-4o',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
message: {
|
||||
content: 'normalized response',
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 6,
|
||||
total_tokens: 16,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.responses(
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
instructions: 'Follow the tool protocol',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello' }],
|
||||
},
|
||||
{
|
||||
type: 'function_call',
|
||||
id: 'call_1',
|
||||
name: 'search_docs',
|
||||
arguments: '{"query":"token"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: { content: 'result: ok' },
|
||||
},
|
||||
],
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
const callArg = proxyService.handleChatCompletions.mock.calls[0][0];
|
||||
expect(callArg.messages[0]).toEqual({
|
||||
role: 'system',
|
||||
content: 'Follow the tool protocol',
|
||||
});
|
||||
expect(callArg.messages.some((message: { role: string }) => message.role === 'assistant')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(callArg.messages.some((message: { role: string }) => message.role === 'tool')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
object: 'text_completion',
|
||||
model: 'gpt-4o',
|
||||
choices: [
|
||||
expect.objectContaining({
|
||||
text: 'normalized response',
|
||||
logprobs: null,
|
||||
}),
|
||||
],
|
||||
usage: expect.objectContaining({
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 6,
|
||||
total_tokens: 16,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('supports image generations endpoint', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageGenerations(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'draw a cat',
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
b64_json: 'AAAABBBB',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('maps image generation upstream quota errors to 429', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockRejectedValue(new Error('429 quota exceeded')),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageGenerations(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'draw a dog',
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(429);
|
||||
});
|
||||
|
||||
it('falls back to Gemini image generation when chat path hits project context error', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new Error(
|
||||
'You are currently configured to use a Google Cloud Project but lack a Gemini Code Assist license. (#3501)',
|
||||
),
|
||||
),
|
||||
handleGeminiGenerateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: 'FALLBACKIMG',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageGenerations(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'draw a fox',
|
||||
},
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleChatCompletions).toHaveBeenCalledOnce();
|
||||
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledOnce();
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
b64_json: 'FALLBACKIMG',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('supports image edits endpoint with supplementary image payload', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn().mockResolvedValue({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageEdits(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'make it brighter',
|
||||
image: 'data:image/png;base64,IMGBASE64',
|
||||
reference_images: ['data:image/jpeg;base64,REFBASE64'],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'multipart/form-data; boundary=----parity',
|
||||
},
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
const request = proxyService.handleChatCompletions.mock.calls[0][0];
|
||||
expect(Array.isArray(request.messages[0].content)).toBe(true);
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('rejects image edits request without multipart boundary', async () => {
|
||||
const proxyService = {
|
||||
handleChatCompletions: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.imageEdits(
|
||||
{
|
||||
model: 'gemini-3-pro-image',
|
||||
prompt: 'make it brighter',
|
||||
image: 'data:image/png;base64,IMGBASE64',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleChatCompletions).not.toHaveBeenCalled();
|
||||
expect(reply.status).toHaveBeenCalledWith(400);
|
||||
expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request');
|
||||
});
|
||||
|
||||
it('supports audio transcriptions endpoint', async () => {
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'transcribed text' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.audioTranscriptions(
|
||||
{
|
||||
model: 'gemini-2.5-flash',
|
||||
file: 'data:audio/mpeg;base64,QUJDRA==',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'multipart/form-data; boundary=----parity',
|
||||
},
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleGeminiGenerateContent).toHaveBeenCalledOnce();
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
expect(reply.send).toHaveBeenCalledWith({ text: 'transcribed text' });
|
||||
});
|
||||
|
||||
it('rejects audio transcription request without multipart boundary', async () => {
|
||||
const proxyService = {
|
||||
handleGeminiGenerateContent: vi.fn(),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.audioTranscriptions(
|
||||
{
|
||||
model: 'gemini-2.5-flash',
|
||||
file: 'data:audio/mpeg;base64,QUJDRA==',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleGeminiGenerateContent).not.toHaveBeenCalled();
|
||||
expect(reply.status).toHaveBeenCalledWith(400);
|
||||
expect(reply.send).toHaveBeenCalledWith('Invalid `boundary` for `multipart/form-data` request');
|
||||
});
|
||||
|
||||
it('supports model detect endpoint', () => {
|
||||
const proxyService = {
|
||||
detectModelCapabilities: vi.fn().mockReturnValue({
|
||||
model: 'claude-sonnet-4-5',
|
||||
mapped_model: 'gemini-3-pro-preview',
|
||||
type: 'generate-content',
|
||||
features: { has_web_search: false, is_image_gen: false },
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
controller.detectModel({ model: 'claude-sonnet-4-5' }, reply as any);
|
||||
|
||||
expect(proxyService.detectModelCapabilities).toHaveBeenCalledWith('claude-sonnet-4-5');
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('supports Anthropic messages endpoint', async () => {
|
||||
const proxyService = {
|
||||
handleAnthropicMessages: vi.fn().mockResolvedValue({
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
}),
|
||||
};
|
||||
const controller = new ProxyController(proxyService as any);
|
||||
const reply = createReplyMock();
|
||||
|
||||
await controller.anthropicMessages(
|
||||
{
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any,
|
||||
reply as any,
|
||||
);
|
||||
|
||||
expect(proxyService.handleAnthropicMessages).toHaveBeenCalledOnce();
|
||||
expect(reply.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
|
||||
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn(), markAsForbidden: vi.fn() };
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(mockTokenManager as any, mockGeminiClient as any);
|
||||
}
|
||||
|
||||
public toAnthropic(request: any): any {
|
||||
return (this as any).convertOpenAIToClaude(request);
|
||||
}
|
||||
|
||||
public toOpenAI(response: any, model: string): any {
|
||||
return (this as any).convertClaudeToOpenAIResponse(response, model);
|
||||
}
|
||||
|
||||
public streamToOpenAI(upstreamStream: any, model: string): Observable<string> {
|
||||
return (this as any).processStreamResponse(upstreamStream, model);
|
||||
}
|
||||
}
|
||||
|
||||
function readFixture<T>(relativePath: string): T {
|
||||
const fullPath = path.join(process.cwd(), 'src/tests/fixtures/proxy-parity', relativePath);
|
||||
return JSON.parse(readFileSync(fullPath, 'utf-8')) as T;
|
||||
}
|
||||
|
||||
describe('Proxy Parity Fixtures', () => {
|
||||
it('maps OpenAI request fixture to expected Anthropic request semantics', () => {
|
||||
const service = new TestableProxyService();
|
||||
const input = readFixture<any>('request/openai.chat-tools.input.json');
|
||||
const expected = readFixture<any>('request/openai.chat-tools.expected.json');
|
||||
|
||||
const actual = service.toAnthropic(input);
|
||||
|
||||
expect(actual.model).toBe(expected.model);
|
||||
expect(actual.system).toBe(expected.system);
|
||||
expect(actual.temperature).toBe(expected.temperature);
|
||||
expect(actual.max_tokens).toBe(expected.max_tokens);
|
||||
expect(actual.tools?.[0]?.name).toBe(expected.tools[0].name);
|
||||
expect(actual.messages[0]).toEqual(expected.messages[0]);
|
||||
expect(actual.messages[1].content[1]).toEqual(expected.messages[1].content[1]);
|
||||
expect(actual.messages[2].content[0]).toEqual(expected.messages[2].content[0]);
|
||||
});
|
||||
|
||||
it('maps Anthropic response fixture to expected OpenAI response semantics', () => {
|
||||
const service = new TestableProxyService();
|
||||
const input = readFixture<any>('response/anthropic.tool-use.input.json');
|
||||
const expected = readFixture<any>('response/anthropic.tool-use.expected.json');
|
||||
|
||||
const actual = service.toOpenAI(input, expected.model);
|
||||
|
||||
expect(actual.model).toBe(expected.model);
|
||||
expect(actual.choices[0].message.content).toBe(expected.message.content);
|
||||
expect(actual.choices[0].message.reasoning_content).toBe(expected.message.reasoning_content);
|
||||
expect(actual.choices[0].message.tool_calls?.[0]).toEqual(expected.message.tool_calls[0]);
|
||||
expect(actual.choices[0].finish_reason).toBe(expected.finish_reason);
|
||||
expect(actual.usage).toEqual(expected.usage);
|
||||
});
|
||||
|
||||
it('maps upstream stream fixture into expected OpenAI SSE semantics', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const input = readFixture<any>('stream/openai-from-gemini.input.json');
|
||||
const expected = readFixture<{ contains: string[] }>('stream/openai-from-gemini.expected.json');
|
||||
|
||||
const stream = new EventEmitter();
|
||||
const outputChunks: string[] = [];
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
service.streamToOpenAI(stream, input.model).subscribe({
|
||||
next: (chunk) => outputChunks.push(chunk),
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
stream.emit('data', Buffer.from(`data: ${JSON.stringify(input.upstream)}\n`));
|
||||
stream.emit('end');
|
||||
|
||||
await promise;
|
||||
|
||||
const output = outputChunks.join('');
|
||||
for (const token of expected.contains) {
|
||||
expect(output).toContain(token);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// Mock dependencies
|
||||
const mockTokenManager = { getNextToken: vi.fn(), markAsRateLimited: vi.fn() };
|
||||
const mockTokenManager = {
|
||||
getNextToken: vi.fn(),
|
||||
markAsRateLimited: vi.fn(),
|
||||
markAsForbidden: vi.fn(),
|
||||
};
|
||||
const mockGeminiClient = { streamGenerateInternal: vi.fn(), generateInternal: vi.fn() };
|
||||
|
||||
// Subclass to access private method
|
||||
@@ -17,9 +21,77 @@ class TestableProxyService extends ProxyService {
|
||||
// Access private method using type assertion
|
||||
return (this as any).processAnthropicInternalStream(stream, model);
|
||||
}
|
||||
|
||||
public testPassthroughStream(stream: any): Observable<string> {
|
||||
return (this as any).passthroughSseStream(stream);
|
||||
}
|
||||
|
||||
public testModelHeaders(model: string): Record<string, string> {
|
||||
return (this as any).buildModelSpecificHeaders(model);
|
||||
}
|
||||
}
|
||||
|
||||
function createToken(id: string = 'acc-1') {
|
||||
return {
|
||||
id,
|
||||
email: `${id}@test.com`,
|
||||
token: {
|
||||
access_token: 'token',
|
||||
refresh_token: 'refresh',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
project_id: 'project-1',
|
||||
session_id: 'session-1',
|
||||
upstream_proxy_url: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('classifies retry matrix consistently', () => {
|
||||
const service = new TestableProxyService();
|
||||
const classify = (message: string) => (service as any).classifyUpstreamError(message);
|
||||
|
||||
expect(classify('401 unauthorized token')).toEqual({
|
||||
retry: true,
|
||||
markAsForbidden: true,
|
||||
markAsRateLimited: false,
|
||||
});
|
||||
expect(classify('403 permission_denied')).toEqual({
|
||||
retry: true,
|
||||
markAsForbidden: true,
|
||||
markAsRateLimited: false,
|
||||
});
|
||||
expect(classify('429 quota exceeded')).toEqual({
|
||||
retry: true,
|
||||
markAsForbidden: false,
|
||||
markAsRateLimited: true,
|
||||
});
|
||||
expect(classify('500 internal error')).toEqual({
|
||||
retry: true,
|
||||
markAsForbidden: false,
|
||||
markAsRateLimited: false,
|
||||
});
|
||||
expect(classify('400 invalid argument')).toEqual({
|
||||
retry: false,
|
||||
markAsForbidden: false,
|
||||
markAsRateLimited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Claude-specific beta headers consistently', () => {
|
||||
const service = new TestableProxyService();
|
||||
const claudeHeaders = service.testModelHeaders('claude-sonnet-4-5');
|
||||
const geminiHeaders = service.testModelHeaders('gemini-2.5-flash');
|
||||
|
||||
expect(claudeHeaders['anthropic-beta']).toContain('claude-code-20250219');
|
||||
expect(geminiHeaders).toEqual({});
|
||||
});
|
||||
|
||||
it('should emit error when stream ends without data', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
@@ -89,4 +161,454 @@ describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
// Actually our mapper might produce "message_start", "content_block_start" etc.
|
||||
// We just care that it didn't error with "Empty response stream"
|
||||
});
|
||||
|
||||
it('falls back to stream aggregation when non-stream response is empty', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
|
||||
mockGeminiClient.generateInternal.mockResolvedValueOnce({ candidates: [] });
|
||||
mockGeminiClient.streamGenerateInternal.mockResolvedValueOnce(stream);
|
||||
|
||||
const promise = (service as any).generateInternalWithStreamFallback(
|
||||
{ model: 'gemini-2.5-flash' },
|
||||
'token',
|
||||
undefined,
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
const payload = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: 'fallback text' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
stream.emit('data', Buffer.from(`data: ${payload}\n\n`));
|
||||
stream.emit('end');
|
||||
}, 10);
|
||||
|
||||
const result = await promise;
|
||||
expect(mockGeminiClient.streamGenerateInternal).toHaveBeenCalledOnce();
|
||||
expect(result.candidates[0].content.parts[0].text).toBe('fallback text');
|
||||
expect(result.candidates[0].finishReason).toBe('STOP');
|
||||
});
|
||||
|
||||
it('injects Claude beta headers when handling Gemini-compatible Claude models', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken());
|
||||
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] } }],
|
||||
});
|
||||
|
||||
await service.handleGeminiGenerateContent('models/claude-sonnet-4-5', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
const headers = mockGeminiClient.generateInternal.mock.calls[0][3];
|
||||
expect(headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||
});
|
||||
|
||||
it('tolerates malformed partial chunks and still completes Anthropic stream', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const resultObservable = service.testProcessStream(stream);
|
||||
|
||||
let completed = false;
|
||||
let errored = false;
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
resultObservable.subscribe({
|
||||
next: () => {},
|
||||
error: () => {
|
||||
errored = true;
|
||||
resolve();
|
||||
},
|
||||
complete: () => {
|
||||
completed = true;
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
stream.emit('data', Buffer.from('data: {"invalid_json":\n\n'));
|
||||
const validPayload = JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
});
|
||||
stream.emit('data', Buffer.from(`data: ${validPayload}\n\n`));
|
||||
stream.emit('end');
|
||||
}, 10);
|
||||
|
||||
await done;
|
||||
|
||||
expect(errored).toBe(false);
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it('raises error for empty Gemini passthrough stream', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
|
||||
const observable = service.testPassthroughStream(stream);
|
||||
let errorMessage = '';
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
observable.subscribe({
|
||||
next: () => {},
|
||||
error: (error: Error) => {
|
||||
errorMessage = error.message;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => stream.emit('end'), 10);
|
||||
await done;
|
||||
|
||||
expect(errorMessage).toBe('Empty response stream');
|
||||
});
|
||||
|
||||
it('propagates Anthropic stream interruption errors', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testProcessStream(stream);
|
||||
let errorMessage = '';
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
observable.subscribe({
|
||||
next: () => {},
|
||||
error: (error: Error) => {
|
||||
errorMessage = error.message;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => stream.emit('error', new Error('upstream interrupted')), 10);
|
||||
await done;
|
||||
|
||||
expect(errorMessage).toBe('upstream interrupted');
|
||||
});
|
||||
|
||||
it('propagates Gemini passthrough interruption errors', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = service.testPassthroughStream(stream);
|
||||
let errorMessage = '';
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
observable.subscribe({
|
||||
next: () => {},
|
||||
error: (error: Error) => {
|
||||
errorMessage = error.message;
|
||||
resolve();
|
||||
},
|
||||
complete: () => resolve(),
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => stream.emit('error', new Error('connection reset by peer')), 10);
|
||||
await done;
|
||||
|
||||
expect(errorMessage).toBe('connection reset by peer');
|
||||
});
|
||||
|
||||
it('retries OpenAI flow with the same error classification matrix', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const token1 = createToken('acc-1');
|
||||
const token2 = createToken('acc-2');
|
||||
mockTokenManager.getNextToken.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
|
||||
mockGeminiClient.generateInternal
|
||||
.mockRejectedValueOnce(new Error('429 quota exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
const result = await service.handleChatCompletions({
|
||||
model: 'gpt-4o',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any);
|
||||
|
||||
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(2);
|
||||
expect(mockTokenManager.markAsRateLimited).toHaveBeenCalledWith('acc-1');
|
||||
expect((result as any).choices?.[0]?.message?.content).toBeDefined();
|
||||
});
|
||||
|
||||
it('retries Anthropic flow with the same error classification matrix', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const token1 = createToken('acc-1');
|
||||
const token2 = createToken('acc-2');
|
||||
mockTokenManager.getNextToken.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
|
||||
mockGeminiClient.generateInternal
|
||||
.mockRejectedValueOnce(new Error('429 rate limit exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
const result = await service.handleAnthropicMessages({
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
max_tokens: 256,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
} as any);
|
||||
|
||||
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(2);
|
||||
expect(mockTokenManager.markAsRateLimited).toHaveBeenCalledWith('acc-1');
|
||||
expect((result as any).type).toBe('message');
|
||||
});
|
||||
|
||||
it('retries Gemini flow with the same error classification matrix', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const token1 = createToken('acc-1');
|
||||
const token2 = createToken('acc-2');
|
||||
mockTokenManager.getNextToken.mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
|
||||
mockGeminiClient.generateInternal
|
||||
.mockRejectedValueOnce(new Error('429 quota exceeded'))
|
||||
.mockResolvedValueOnce({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
const result = await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(2);
|
||||
expect(mockTokenManager.markAsRateLimited).toHaveBeenCalledWith('acc-1');
|
||||
expect((result as any).candidates?.[0]?.content?.parts?.[0]?.text).toBe('ok');
|
||||
});
|
||||
|
||||
it('does not include sessionId in Gemini internal generate payload', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
const internalPayload = mockGeminiClient.generateInternal.mock.calls[0][0];
|
||||
expect(internalPayload).not.toHaveProperty('sessionId');
|
||||
});
|
||||
|
||||
it('strips non-parity Gemini usage metadata fields', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: 'model', parts: [{ text: 'ok' }] },
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 3,
|
||||
thoughtsTokenCount: 4,
|
||||
},
|
||||
responseId: 'resp_123',
|
||||
createTime: '2026-02-10T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const result = await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
expect((result as any).usageMetadata).toEqual({
|
||||
promptTokenCount: 1,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 3,
|
||||
});
|
||||
expect((result as any).usageMetadata.thoughtsTokenCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries Gemini generate-content without project when project context is invalid', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
mockGeminiClient.generateInternal
|
||||
.mockRejectedValueOnce(
|
||||
new Error(
|
||||
'You are currently configured to use a Google Cloud Project but lack a Gemini Code Assist license. (#3501)',
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
const result = await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(1);
|
||||
expect(mockGeminiClient.generateInternal).toHaveBeenCalledTimes(2);
|
||||
expect(mockGeminiClient.generateInternal.mock.calls[0][0].project).toBe('project-1');
|
||||
expect(mockGeminiClient.generateInternal.mock.calls[1][0].project).toBe('');
|
||||
expect((result as any).candidates?.[0]?.content?.parts?.[0]?.text).toBe('ok');
|
||||
});
|
||||
|
||||
it('uses generate-content requestType for Gemini stream internal payload', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
mockGeminiClient.streamGenerateInternal.mockResolvedValue(new EventEmitter());
|
||||
|
||||
await service.handleGeminiStreamGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
const internalPayload = mockGeminiClient.streamGenerateInternal.mock.calls[0][0];
|
||||
expect(internalPayload.requestType).toBe('generate-content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxyService Protocol Parity Fixtures', () => {
|
||||
it('maps OpenAI request to Anthropic request with tools and tool result', () => {
|
||||
const service = new TestableProxyService();
|
||||
|
||||
const openaiRequest = {
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
temperature: 0.2,
|
||||
max_tokens: 512,
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_docs',
|
||||
description: 'Search docs',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a precise assistant.' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'Find API key docs' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Calling search tool',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_docs',
|
||||
arguments: '{"query":"api key"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_1',
|
||||
name: 'search_docs',
|
||||
content: 'Found 3 results',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const anthropicRequest = (service as any).convertOpenAIToClaude(openaiRequest);
|
||||
|
||||
expect(anthropicRequest.system).toContain('You are a precise assistant.');
|
||||
expect(anthropicRequest.tools?.[0]?.name).toBe('search_docs');
|
||||
expect(anthropicRequest.messages.length).toBe(3);
|
||||
|
||||
const assistantMessage = anthropicRequest.messages[1];
|
||||
expect(Array.isArray(assistantMessage.content)).toBe(true);
|
||||
expect(assistantMessage.content.some((block: any) => block.type === 'tool_use')).toBe(true);
|
||||
|
||||
const toolResultMessage = anthropicRequest.messages[2];
|
||||
expect(toolResultMessage.role).toBe('user');
|
||||
expect(Array.isArray(toolResultMessage.content)).toBe(true);
|
||||
expect(toolResultMessage.content[0].type).toBe('tool_result');
|
||||
});
|
||||
|
||||
it('maps Anthropic response to OpenAI response with reasoning and tool_calls', () => {
|
||||
const service = new TestableProxyService();
|
||||
|
||||
const anthropicResponse = {
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Need to call tool first.' },
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-1',
|
||||
name: 'search_docs',
|
||||
input: { query: 'api key' },
|
||||
},
|
||||
{ type: 'text', text: 'Here are the docs.' },
|
||||
],
|
||||
stop_reason: 'tool_use',
|
||||
usage: {
|
||||
input_tokens: 20,
|
||||
output_tokens: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const openaiResponse = (service as any).convertClaudeToOpenAIResponse(
|
||||
anthropicResponse,
|
||||
'gpt-4o-mini',
|
||||
);
|
||||
|
||||
expect(openaiResponse.model).toBe('gpt-4o-mini');
|
||||
expect(openaiResponse.choices[0].message.role).toBe('assistant');
|
||||
expect(openaiResponse.choices[0].message.reasoning_content).toContain('Need to call tool');
|
||||
expect(openaiResponse.choices[0].message.tool_calls?.length).toBe(1);
|
||||
expect(openaiResponse.choices[0].finish_reason).toBe('tool_calls');
|
||||
});
|
||||
|
||||
it('converts internal SSE stream into OpenAI SSE chunks', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const stream = new EventEmitter();
|
||||
const observable = (service as any).processStreamResponse(stream, 'gpt-4o-mini');
|
||||
|
||||
const chunks: string[] = [];
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
observable.subscribe({
|
||||
next: (chunk: string) => {
|
||||
chunks.push(chunk);
|
||||
},
|
||||
error: reject,
|
||||
complete: resolve,
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{ thought: true, text: 'reasoning text' },
|
||||
{ functionCall: { id: 'fc1', name: 'search_docs', args: { query: 'api key' } } },
|
||||
{ text: 'final answer' },
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
stream.emit('data', Buffer.from(`data: ${payload}\n`));
|
||||
stream.emit('end');
|
||||
});
|
||||
|
||||
const output = chunks.join('');
|
||||
expect(output).toContain('"reasoning_content":"reasoning text"');
|
||||
expect(output).toContain('"tool_calls"');
|
||||
expect(output).toContain('"content":"final answer"');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user