Files
makelore/tests/unit/ai-proxy-routes.test.ts
2026-07-29 17:22:35 +08:00

483 lines
16 KiB
TypeScript

import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'http';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleAiProxyRoutes } from '@electron/api/routes/ai-proxy';
import {
clearWorksSquareAIGatewayCredential,
seedWorksSquareAIGatewayCredential,
} from '@electron/services/works-square-ai-gateway';
import {
clearWorksSquareSession,
storeWorksSquareSession,
} from '@electron/services/works-square-session';
const loggerWarnMock = vi.hoisted(() => vi.fn());
vi.mock('@electron/utils/logger', () => ({
logger: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: (...args: unknown[]) => loggerWarnMock(...args),
},
}));
function createRequest(
method: string,
body?: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const req = new EventEmitter();
const rawBody = body === undefined
? undefined
: (typeof body === 'string' ? body : JSON.stringify(body));
Object.assign(req, {
method,
headers: {
host: '127.0.0.1:13210',
authorization: 'Bearer local-host-api-token',
'content-type': 'application/json',
...(rawBody ? { 'content-length': String(Buffer.byteLength(rawBody)) } : {}),
...headers,
},
[Symbol.asyncIterator]: async function* () {
if (rawBody !== undefined) {
yield Buffer.from(rawBody);
}
},
});
return req as IncomingMessage;
}
function createResponse(options: { writeResults?: boolean[] } = {}) {
const chunks: string[] = [];
const headers = new Map<string, number | string | string[]>();
const writeResults = [...(options.writeResults ?? [])];
const socket = { setNoDelay: vi.fn() };
const res = new EventEmitter();
Object.assign(res, {
statusCode: 0,
socket,
flushHeaders: vi.fn(),
setHeader: vi.fn((name: string, value: number | string | string[]) => {
headers.set(name.toLowerCase(), value);
}),
write: vi.fn((chunk: string | Uint8Array) => {
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
return writeResults.shift() ?? true;
}),
end: vi.fn((chunk?: string | Uint8Array) => {
if (chunk !== undefined) {
chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
}
}),
});
return {
res: res as unknown as ServerResponse,
socket,
get statusCode() {
return (res as { statusCode: number }).statusCode;
},
header: (name: string) => headers.get(name.toLowerCase()),
body: () => chunks.join(''),
};
}
function streamFromText(...chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
},
});
}
async function flushMicrotasks(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await Promise.resolve();
}
}
describe('ai proxy routes', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
loggerWarnMock.mockReset();
clearWorksSquareAIGatewayCredential();
clearWorksSquareSession();
});
afterEach(() => {
vi.useRealTimers();
clearWorksSquareAIGatewayCredential();
clearWorksSquareSession();
vi.unstubAllGlobals();
});
it('forwards OpenAI-compatible JSON requests to one-api with a fresh Works Square gateway token', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'chatcmpl_1' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
const handled = await handleAiProxyRoutes(
createRequest('POST', { model: 'deepseek-chat', messages: [] }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions?trace=1'),
{} as never,
);
expect(handled).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'https://one-api.example.com/v1/chat/completions?trace=1',
expect.objectContaining({
method: 'POST',
body: Buffer.from(JSON.stringify({ model: 'deepseek-chat', messages: [] })),
headers: expect.objectContaining({
'content-type': 'application/json',
'x-works-square-ai-token': 'ws-ai-token',
}),
}),
);
const forwardedHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(forwardedHeaders.authorization).toBeUndefined();
expect(forwardedHeaders.host).toBeUndefined();
expect(response.statusCode).toBe(200);
expect(response.header('content-type')).toBe('application/json');
expect(response.body()).toBe(JSON.stringify({ id: 'chatcmpl_1' }));
});
it('refreshes a near-expiry gateway credential before forwarding a long-running task request', async () => {
storeWorksSquareSession({
accessToken: 'works-access-token',
refreshToken: 'works-refresh-token',
expiresAt: Date.now() + 120_000,
});
seedWorksSquareAIGatewayCredential({
accessToken: 'near-expiry-ai-token',
expiresIn: 30,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
access_token: 'fresh-ai-token',
expires_in: 1800,
one_api_base_url: 'https://one-api.example.com/v1',
}), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ id: 'chatcmpl_refresh' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'qwen3.7-max', messages: [] }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe('https://square.nianxx.cn/api/ai-gateway/session');
expect(fetchMock.mock.calls[1][1].headers).toMatchObject({
'x-works-square-ai-token': 'fresh-ai-token',
});
expect(response.statusCode).toBe(200);
expect(response.body()).toBe(JSON.stringify({ id: 'chatcmpl_refresh' }));
});
it('refreshes the gateway credential and retries once when one-api rejects an expired gateway token', async () => {
storeWorksSquareSession({
accessToken: 'works-access-token',
refreshToken: 'works-refresh-token',
expiresAt: Date.now() + 120_000,
});
seedWorksSquareAIGatewayCredential({
accessToken: 'expired-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response('AI access token expired', { status: 401 }))
.mockResolvedValueOnce(
new Response(JSON.stringify({
access_token: 'fresh-ai-token',
expires_in: 1800,
one_api_base_url: 'https://one-api.example.com/v1',
}), { status: 200 }),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ id: 'chatcmpl_2' }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'deepseek-chat' }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
'x-works-square-ai-token': 'expired-ai-token',
});
expect(fetchMock.mock.calls[1][0]).toBe('https://square.nianxx.cn/api/ai-gateway/session');
expect(fetchMock.mock.calls[2][1].headers).toMatchObject({
'x-works-square-ai-token': 'fresh-ai-token',
});
expect(response.statusCode).toBe(200);
expect(response.body()).toBe(JSON.stringify({ id: 'chatcmpl_2' }));
});
it('does not retry quota failures', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ error: 'user quota is not enough' }), {
status: 429,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'deepseek-chat' }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(fetchMock).toHaveBeenCalledOnce();
expect(response.statusCode).toBe(402);
expect(response.body()).toBe(JSON.stringify({ error: 'user quota is not enough' }));
});
it('maps rolling-window quota exhaustion to a non-retryable response status', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
error: {
message: 'Token quota exhausted for rolling 5-hour window',
code: 'works_square_gateway_authorize_failed',
type: 'one_api_error',
},
}), {
status: 429,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'qwen3.6-plus', messages: [] }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(fetchMock).toHaveBeenCalledOnce();
expect(response.statusCode).toBe(402);
expect(response.body()).toContain('Token quota exhausted for rolling 5-hour window');
});
it('preserves the upstream status for a generic gateway authorization failure', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
error: {
message: 'works_square_gateway_authorize_failed',
code: 'works_square_gateway_authorize_failed',
type: 'one_api_error',
},
}), {
status: 429,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'qwen3.6-plus', messages: [] }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(fetchMock).toHaveBeenCalledOnce();
expect(response.statusCode).toBe(429);
expect(response.body()).toContain('works_square_gateway_authorize_failed');
});
it('logs a sanitized one-api error summary when the upstream group is saturated', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({
error: {
message: '当前分组上游负载已饱和,请稍后再试 (request id: req-saturated)',
code: 'rate_limit_exceeded',
type: 'one_api_error',
},
}), {
status: 429,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'qwen3.7-max', messages: [{ role: 'user', content: 'secret prompt' }] }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(response.statusCode).toBe(429);
expect(response.body()).toContain('当前分组上游负载已饱和');
expect(loggerWarnMock).toHaveBeenCalledWith(
'[ai-proxy] One-api returned non-success response',
expect.objectContaining({
status: 429,
url: 'https://one-api.example.com/v1/chat/completions',
code: 'rate_limit_exceeded',
type: 'one_api_error',
message: '当前分组上游负载已饱和,请稍后再试 (request id: req-saturated)',
}),
);
expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('secret prompt');
expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('ws-ai-token');
});
it('strips decoded compression headers from proxied responses', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'chatcmpl_br' }), {
status: 200,
headers: {
'content-encoding': 'br',
'content-length': '999',
'content-type': 'application/json',
},
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { model: 'deepseek-chat' }, { 'accept-encoding': 'br, gzip' }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
const forwardedHeaders = fetchMock.mock.calls[0][1].headers as Record<string, string>;
expect(forwardedHeaders['accept-encoding']).toBeUndefined();
expect(response.statusCode).toBe(200);
expect(response.header('content-type')).toBe('application/json');
expect(response.header('content-encoding')).toBeUndefined();
expect(response.header('content-length')).toBeUndefined();
expect(response.body()).toBe(JSON.stringify({ id: 'chatcmpl_br' }));
});
it('passes streaming chunks through to the local client', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(streamFromText('data: one\n\n', 'data: two\n\n'), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse();
await handleAiProxyRoutes(
createRequest('POST', { stream: true }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
);
expect(response.statusCode).toBe(200);
expect(response.header('content-type')).toBe('text/event-stream');
expect(response.body()).toBe('data: one\n\ndata: two\n\n');
});
it('flushes streaming headers and waits for downstream drain before reading more chunks', async () => {
seedWorksSquareAIGatewayCredential({
accessToken: 'ws-ai-token',
expiresIn: 3600,
oneApiBaseUrl: 'https://one-api.example.com/v1',
});
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(streamFromText('data: one\n\n', 'data: two\n\n'), {
status: 200,
headers: { 'content-type': 'text/event-stream' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const response = createResponse({ writeResults: [false, true] });
let settled = false;
const handledPromise = handleAiProxyRoutes(
createRequest('POST', { stream: true }),
response.res,
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
{} as never,
).finally(() => {
settled = true;
});
await flushMicrotasks();
expect(response.res.flushHeaders).toHaveBeenCalledOnce();
expect(response.socket.setNoDelay).toHaveBeenCalledWith(true);
expect(response.body()).toBe('data: one\n\n');
expect(settled).toBe(false);
response.res.emit('drain');
await handledPromise;
expect(settled).toBe(true);
expect(response.body()).toBe('data: one\n\ndata: two\n\n');
});
});