- Add smart JSON export/import with schema validation and deduplication - Add per-account proxy configurations (Issue #79) - Add advanced account sorting engine (Issues #126 & #99) - Add compact horizontal layout, AI credits, inline model visibility (Issues #145 & #134) - Add desktop push notifications for low quota (Issue #53) - Fix Arch Linux AUR process detection bug (Issue #117) - Default theme set to Dark Mode (it's better) - UI Polishing: Improved sort dropdown and symmetrical dialog layouts - Dev Mode: Resolved port conflicts and single-instance locks - Updated README and README.zh-CN with new features
520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
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('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 OpenAI responses compatibility endpoint in stream mode with SSE headers', async () => {
|
|
const stream = of('data: {"id":"chatcmpl_resp_stream"}\n\n');
|
|
const proxyService = {
|
|
handleChatCompletions: vi.fn().mockResolvedValue(stream),
|
|
};
|
|
|
|
const controller = new ProxyController(proxyService as any);
|
|
const reply = createReplyMock();
|
|
|
|
await controller.responses(
|
|
{
|
|
model: 'gpt-4o',
|
|
instructions: 'stream output',
|
|
input: 'hello',
|
|
stream: true,
|
|
},
|
|
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('normalizes web_search_call in /v1/responses into builtin_web_search tool messages', async () => {
|
|
const proxyService = {
|
|
handleChatCompletions: vi.fn().mockResolvedValue({
|
|
id: 'chatcmpl_resp_search',
|
|
object: 'chat.completion',
|
|
created: 1700000002,
|
|
model: 'gpt-4o',
|
|
choices: [{ index: 0, finish_reason: 'stop', message: { content: 'done' } }],
|
|
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
}),
|
|
};
|
|
|
|
const controller = new ProxyController(proxyService as any);
|
|
const reply = createReplyMock();
|
|
|
|
await controller.responses(
|
|
{
|
|
model: 'gpt-4o',
|
|
input: [
|
|
{
|
|
type: 'web_search_call',
|
|
call_id: 'call_search_1',
|
|
action: { query: 'gemini api' },
|
|
},
|
|
{
|
|
type: 'function_call_output',
|
|
call_id: 'call_search_1',
|
|
output: { content: 'search result' },
|
|
},
|
|
],
|
|
},
|
|
reply as any,
|
|
);
|
|
|
|
const callArg = proxyService.handleChatCompletions.mock.calls[0][0];
|
|
const assistantMessage = callArg.messages.find(
|
|
(message: { role: string }) => message.role === 'assistant',
|
|
);
|
|
const toolMessage = callArg.messages.find(
|
|
(message: { role: string }) => message.role === 'tool',
|
|
);
|
|
|
|
expect(assistantMessage?.tool_calls?.[0]?.function?.name).toBe('builtin_web_search');
|
|
expect(toolMessage?.name).toBe('builtin_web_search');
|
|
});
|
|
|
|
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 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);
|
|
});
|
|
});
|