mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(platform): support live system proxy changes * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Honor lowercase proxy env vars within a single source before merging proxy-aware envs.\n\nGenerated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Refresh provider request proxy env on each dispatcher creation and cover it with a focused regression test. Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): enable node env proxy for user proxy vars * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode) * fix(platform): support live system proxy changes Generated-By: looper 0.9.1 (runner=fixer, agent=opencode)
1630 lines
59 KiB
TypeScript
1630 lines
59 KiB
TypeScript
import type http from 'node:http';
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeAll, afterAll, describe, expect, it, vi } from 'vitest';
|
|
import * as platform from '@open-design/platform';
|
|
import { startServer } from '../src/server.js';
|
|
|
|
type FetchInput = Parameters<typeof fetch>[0];
|
|
type FetchInit = Parameters<typeof fetch>[1];
|
|
|
|
describe('API proxy routes', () => {
|
|
const realFetch = globalThis.fetch;
|
|
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startServer({ port: 0, returnServer: true }) as {
|
|
url: string;
|
|
server: http.Server;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalMediaConfigDir == null) delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
else process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
});
|
|
|
|
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
|
|
it('converts OpenAI-compatible CRLF SSE chunks into proxy delta/end events', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse([
|
|
'data: {"choices":[{"delta":',
|
|
'data: {"content":"hi"}}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\r\n')));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.example.com/v1',
|
|
apiKey: 'sk-test',
|
|
model: 'gpt-test',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
await expect(res.text()).resolves.toContain('event: delta\ndata: {"delta":"hi"}');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.example.com/v1/chat/completions',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer sk-test' }),
|
|
redirect: 'error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
provider: 'anthropic',
|
|
path: '/api/proxy/anthropic/stream',
|
|
body: {
|
|
baseUrl: 'https://api.anthropic.com',
|
|
apiKey: 'sk-ant',
|
|
model: 'claude-test',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
},
|
|
response: sseResponse('event: message_stop\ndata: {}\n\n'),
|
|
},
|
|
{
|
|
provider: 'openai',
|
|
path: '/api/proxy/openai/stream',
|
|
body: {
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
apiKey: 'sk-openai',
|
|
model: 'gpt-test',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
},
|
|
response: sseResponse('data: [DONE]\n\n'),
|
|
},
|
|
{
|
|
provider: 'azure',
|
|
path: '/api/proxy/azure/stream',
|
|
body: {
|
|
baseUrl: 'https://resource.openai.azure.com',
|
|
apiKey: 'azure-key',
|
|
model: 'deployment-one',
|
|
apiVersion: '2024-10-21',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
},
|
|
response: sseResponse('data: [DONE]\n\n'),
|
|
},
|
|
{
|
|
provider: 'google',
|
|
path: '/api/proxy/google/stream',
|
|
body: {
|
|
baseUrl: 'https://generativelanguage.googleapis.com',
|
|
apiKey: 'google-key',
|
|
model: 'gemini-2.0-flash',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
},
|
|
response: sseResponse('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n'),
|
|
},
|
|
{
|
|
provider: 'ollama',
|
|
path: '/api/proxy/ollama/stream',
|
|
body: {
|
|
baseUrl: 'https://ollama.example.com',
|
|
apiKey: 'ollama-key',
|
|
model: 'llama3',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
},
|
|
response: new Response(new TextEncoder().encode('{"done":true}\n'), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/x-ndjson' },
|
|
}),
|
|
},
|
|
{
|
|
provider: 'senseaudio',
|
|
path: '/api/proxy/senseaudio/stream',
|
|
body: {
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-key',
|
|
model: 'senseaudio-s2',
|
|
projectId: 'test-project',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
},
|
|
response: sseResponse('data: [DONE]\n\n'),
|
|
},
|
|
])('uses the live proxy dispatcher for $provider proxy requests', async ({ path, body, response }) => {
|
|
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
|
|
HTTPS_PROXY: 'http://system-proxy.internal:8443',
|
|
NODE_USE_ENV_PROXY: '1',
|
|
});
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
expect(init?.dispatcher).toBeDefined();
|
|
return Promise.resolve(response.clone());
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
try {
|
|
const res = await realFetch(`${baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
await res.text();
|
|
expect(
|
|
fetchMock.mock.calls.some(
|
|
([input, init]) => !String(input).startsWith(baseUrl) && init?.dispatcher,
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
proxySpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('uses the live proxy dispatcher for ElevenLabs voice discovery', async () => {
|
|
const configDir = await mkdtemp(path.join(tmpdir(), 'od-elevenlabs-proxy-route-'));
|
|
process.env.OD_MEDIA_CONFIG_DIR = configDir;
|
|
await mkdir(configDir, { recursive: true });
|
|
await writeFile(path.join(configDir, 'media-config.json'), JSON.stringify({
|
|
providers: {
|
|
elevenlabs: {
|
|
apiKey: 'eleven-test-key',
|
|
baseUrl: 'https://elevenlabs-gateway.example.test',
|
|
},
|
|
},
|
|
}), 'utf8');
|
|
|
|
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
|
|
HTTPS_PROXY: 'http://system-proxy.internal:8443',
|
|
NODE_USE_ENV_PROXY: '1',
|
|
});
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
expect(url).toBe('https://elevenlabs-gateway.example.test/v2/voices?page_size=100');
|
|
expect(init?.dispatcher).toBeDefined();
|
|
return Promise.resolve(Response.json({
|
|
voices: [{ voice_id: 'voice-1', name: 'Rachel' }],
|
|
}));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
try {
|
|
const res = await realFetch(`${baseUrl}/api/media/providers/elevenlabs/voices?limit=100`);
|
|
expect(res.status).toBe(200);
|
|
await expect(res.json()).resolves.toEqual({
|
|
voices: [{ voiceId: 'voice-1', name: 'Rachel' }],
|
|
});
|
|
expect(
|
|
fetchMock.mock.calls.some(
|
|
([input, init]) => input === 'https://elevenlabs-gateway.example.test/v2/voices?page_size=100' && init?.dispatcher,
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
proxySpy.mockRestore();
|
|
await rm(configDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('uses the live proxy dispatcher for Tavily research search', async () => {
|
|
const configDir = await mkdtemp(path.join(tmpdir(), 'od-tavily-proxy-route-'));
|
|
process.env.OD_MEDIA_CONFIG_DIR = configDir;
|
|
await mkdir(configDir, { recursive: true });
|
|
await writeFile(path.join(configDir, 'media-config.json'), JSON.stringify({
|
|
providers: {
|
|
tavily: {
|
|
apiKey: 'tavily-test-key',
|
|
baseUrl: 'https://tavily-gateway.example.test',
|
|
},
|
|
},
|
|
}), 'utf8');
|
|
|
|
const proxySpy = vi.spyOn(platform, 'resolveSystemProxyEnv').mockReturnValue({
|
|
HTTPS_PROXY: 'http://system-proxy.internal:8443',
|
|
NODE_USE_ENV_PROXY: '1',
|
|
});
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
expect(url).toBe('https://tavily-gateway.example.test/search');
|
|
expect(init?.dispatcher).toBeDefined();
|
|
return Promise.resolve(Response.json({
|
|
answer: 'Proxy-safe summary',
|
|
results: [
|
|
{
|
|
title: 'Proxy-safe source',
|
|
url: 'https://example.test/source',
|
|
content: 'Snippet',
|
|
},
|
|
],
|
|
}));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
try {
|
|
const res = await realFetch(`${baseUrl}/api/research/search`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
query: 'proxy-aware research',
|
|
providers: ['tavily'],
|
|
}),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
await expect(res.json()).resolves.toEqual(expect.objectContaining({
|
|
query: 'proxy-aware research',
|
|
provider: 'tavily',
|
|
summary: 'Proxy-safe summary',
|
|
sources: [
|
|
expect.objectContaining({
|
|
title: 'Proxy-safe source',
|
|
url: 'https://example.test/source',
|
|
}),
|
|
],
|
|
}));
|
|
expect(
|
|
fetchMock.mock.calls.some(
|
|
([input, init]) => input === 'https://tavily-gateway.example.test/search' && init?.dispatcher,
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
proxySpy.mockRestore();
|
|
await rm(configDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reports malformed proxy env before sending the start event on Anthropic streams', async () => {
|
|
const originalHttpProxy = process.env.HTTP_PROXY;
|
|
const originalHttpsProxy = process.env.HTTPS_PROXY;
|
|
const originalAllProxy = process.env.ALL_PROXY;
|
|
process.env.HTTP_PROXY = 'not a valid proxy url';
|
|
delete process.env.HTTPS_PROXY;
|
|
delete process.env.ALL_PROXY;
|
|
|
|
try {
|
|
const res = await realFetch(`${baseUrl}/api/proxy/anthropic/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.anthropic.com',
|
|
apiKey: 'sk-ant',
|
|
model: 'claude-test',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const text = await res.text();
|
|
expect(text).toContain('event: error');
|
|
expect(text).toContain('INTERNAL_ERROR');
|
|
expect(text).not.toContain('event: start');
|
|
} finally {
|
|
if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY;
|
|
else process.env.HTTP_PROXY = originalHttpProxy;
|
|
if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY;
|
|
else process.env.HTTPS_PROXY = originalHttpsProxy;
|
|
if (originalAllProxy === undefined) delete process.env.ALL_PROXY;
|
|
else process.env.ALL_PROXY = originalAllProxy;
|
|
}
|
|
});
|
|
|
|
// Regression: appendVersionedApiPath needs to thread three shapes:
|
|
// * bare host → inject /v1 (api.openai.com)
|
|
// * sub-path containing /vN → no inject (api.deepinfra.com/v1/openai)
|
|
// * sub-path without /vN → inject /v1 (api.deepseek.com/anthropic)
|
|
// The earlier end-of-path check broke the second case; a "non-empty
|
|
// path → respect verbatim" intermediate fix broke the third. Pin all
|
|
// three so neither regression returns.
|
|
it.each([
|
|
[
|
|
'https://api.deepinfra.com/v1/openai',
|
|
'https://api.deepinfra.com/v1/openai/chat/completions',
|
|
],
|
|
[
|
|
'https://api.deepinfra.com/v1/openai/',
|
|
'https://api.deepinfra.com/v1/openai/chat/completions',
|
|
],
|
|
[
|
|
'https://openrouter.ai/api/v1',
|
|
'https://openrouter.ai/api/v1/chat/completions',
|
|
],
|
|
[
|
|
'https://api.openai.com',
|
|
'https://api.openai.com/v1/chat/completions',
|
|
],
|
|
[
|
|
'https://api.openai.com/',
|
|
'https://api.openai.com/v1/chat/completions',
|
|
],
|
|
])('routes OpenAI baseUrl %s to %s', async (input, expected) => {
|
|
const fetchMock = vi.fn((req: FetchInput, init?: FetchInit) => {
|
|
const url = String(req);
|
|
if (url.startsWith(baseUrl)) return realFetch(req, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: input,
|
|
apiKey: 'sk-test',
|
|
model: 'm',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(String(fetchMock.mock.calls[0]![0])).toBe(expected);
|
|
});
|
|
|
|
// The Anthropic proxy goes through the same `appendVersionedApiPath`
|
|
// helper, but its preset table includes Anthropic-compatible gateways
|
|
// mounted at non-versioned sub-paths (DeepSeek `/anthropic`, MiniMax
|
|
// `/anthropic`, MiMo `/anthropic`). Those still need the `/v1`
|
|
// injection, otherwise upstream returns 404 on `.../anthropic/messages`.
|
|
it.each([
|
|
[
|
|
'https://api.anthropic.com',
|
|
'https://api.anthropic.com/v1/messages',
|
|
],
|
|
[
|
|
'https://api.deepseek.com/anthropic',
|
|
'https://api.deepseek.com/anthropic/v1/messages',
|
|
],
|
|
[
|
|
'https://api.minimaxi.com/anthropic',
|
|
'https://api.minimaxi.com/anthropic/v1/messages',
|
|
],
|
|
[
|
|
'https://token-plan-cn.xiaomimimo.com/anthropic',
|
|
'https://token-plan-cn.xiaomimimo.com/anthropic/v1/messages',
|
|
],
|
|
])('routes Anthropic baseUrl %s to %s', async (input, expected) => {
|
|
const fetchMock = vi.fn((req: FetchInput, init?: FetchInit) => {
|
|
const url = String(req);
|
|
if (url.startsWith(baseUrl)) return realFetch(req, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/anthropic/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: input,
|
|
apiKey: 'sk-test',
|
|
model: 'm',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(String(fetchMock.mock.calls[0]![0])).toBe(expected);
|
|
});
|
|
|
|
it('allows loopback API base URLs for local OpenAI-compatible providers', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'http://localhost:11434/v1',
|
|
apiKey: 'sk-local',
|
|
model: 'llama-local',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
await expect(res.text()).resolves.toContain('event: end');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://localhost:11434/v1/chat/completions',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer sk-local' }),
|
|
redirect: 'error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('allows IPv4-mapped loopback API base URLs for local OpenAI-compatible providers', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'http://[::ffff:127.0.0.1]:11434/v1',
|
|
apiKey: 'sk-local',
|
|
model: 'llama-local',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
await expect(res.text()).resolves.toContain('event: end');
|
|
expect(String(fetchMock.mock.calls[0]![0])).toBe(
|
|
'http://[::ffff:7f00:1]:11434/v1/chat/completions',
|
|
);
|
|
});
|
|
|
|
it('blocks private network API base URLs before proxying', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'http://192.168.1.50:11434/v1',
|
|
apiKey: 'sk-private',
|
|
model: 'private-model',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
await expect(res.text()).resolves.toContain('Internal IPs blocked');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
'http://0.0.0.0:11434/v1',
|
|
'http://100.64.0.1:11434/v1',
|
|
'http://169.254.169.254/latest/meta-data',
|
|
'http://224.0.0.1:11434/v1',
|
|
'http://[::]/v1',
|
|
'http://[::ffff:192.168.1.50]:11434/v1',
|
|
'http://[fd00::1]:11434/v1',
|
|
'http://[fe80::1]:11434/v1',
|
|
])('blocks local and private API base URL form %s before proxying', async (privateBaseUrl) => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: privateBaseUrl,
|
|
apiKey: 'sk-private',
|
|
model: 'private-model',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(403);
|
|
await expect(res.text()).resolves.toContain('Internal IPs blocked');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces OpenAI-compatible in-stream error frames', async () => {
|
|
vi.stubGlobal('fetch', vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: {"error":{"message":"bad model"}}\n\n'));
|
|
}));
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.example.com/v1',
|
|
apiKey: 'sk-test',
|
|
model: 'bad-model',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
await expect(res.text()).resolves.toContain('Provider error: bad model');
|
|
});
|
|
|
|
it('uses Azure deployment URLs and api-key auth', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://resource.openai.azure.com',
|
|
apiKey: 'azure-key',
|
|
model: 'deployment-one',
|
|
apiVersion: '2024-10-21',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe(
|
|
'https://resource.openai.azure.com/openai/deployments/deployment-one/chat/completions?api-version=2024-10-21',
|
|
);
|
|
expect(upstreamInit?.headers).toMatchObject({ 'api-key': 'azure-key' });
|
|
expect(upstreamInit?.redirect).toBe('error');
|
|
});
|
|
|
|
it('retries Azure OpenAI-compatible v1 alias requests with max_completion_tokens when max_tokens is rejected', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
if ('max_tokens' in body) {
|
|
return Promise.resolve(new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.",
|
|
type: 'invalid_request_error',
|
|
param: 'max_tokens',
|
|
code: 'unsupported_parameter',
|
|
},
|
|
}),
|
|
{
|
|
status: 400,
|
|
headers: { 'content-type': 'application/json' },
|
|
},
|
|
));
|
|
}
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://resource.services.ai.azure.com/api/projects/project/openai/v1',
|
|
apiKey: 'azure-key',
|
|
model: 'prod',
|
|
apiVersion: '',
|
|
maxTokens: 1234,
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const upstreamCalls = fetchMock.mock.calls.filter(
|
|
([input]) => !String(input).startsWith(baseUrl),
|
|
);
|
|
expect(upstreamCalls).toHaveLength(2);
|
|
const firstBody = JSON.parse(String(upstreamCalls[0]![1]?.body));
|
|
const secondBody = JSON.parse(String(upstreamCalls[1]![1]?.body));
|
|
expect(firstBody).toMatchObject({
|
|
model: 'prod',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 1234,
|
|
stream: true,
|
|
});
|
|
expect(firstBody).not.toHaveProperty('max_completion_tokens');
|
|
expect(secondBody).toMatchObject({
|
|
model: 'prod',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_completion_tokens: 1234,
|
|
stream: true,
|
|
});
|
|
expect(secondBody).not.toHaveProperty('max_tokens');
|
|
});
|
|
|
|
it('retries Azure deployment-mode requests with max_completion_tokens when max_tokens is rejected', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
if ('max_tokens' in body) {
|
|
return Promise.resolve(new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.",
|
|
type: 'invalid_request_error',
|
|
param: 'max_tokens',
|
|
code: 'unsupported_parameter',
|
|
},
|
|
}),
|
|
{
|
|
status: 400,
|
|
headers: { 'content-type': 'application/json' },
|
|
},
|
|
));
|
|
}
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://resource.openai.azure.com',
|
|
apiKey: 'azure-key',
|
|
model: 'prod',
|
|
apiVersion: '',
|
|
maxTokens: 1234,
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
await expect(res.text()).resolves.toContain('event: end');
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const firstBody = JSON.parse(String(fetchMock.mock.calls[0]![1]?.body));
|
|
const secondBody = JSON.parse(String(fetchMock.mock.calls[1]![1]?.body));
|
|
expect(firstBody).toMatchObject({ max_tokens: 1234, stream: true });
|
|
expect(firstBody).not.toHaveProperty('max_completion_tokens');
|
|
expect(secondBody).toMatchObject({ max_completion_tokens: 1234, stream: true });
|
|
expect(secondBody).not.toHaveProperty('max_tokens');
|
|
});
|
|
|
|
it('keeps max_tokens for legacy OpenAI-compatible chat-completions payloads', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
apiKey: 'sk-test',
|
|
model: 'gpt-4o',
|
|
maxTokens: 4321,
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({
|
|
model: 'gpt-4o',
|
|
max_tokens: 4321,
|
|
stream: true,
|
|
});
|
|
expect(JSON.parse(String(upstreamInit?.body))).not.toHaveProperty(
|
|
'max_completion_tokens',
|
|
);
|
|
});
|
|
|
|
it('keeps max_tokens for DeepSeek-style OpenAI-compatible hosts', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/openai/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.deepseek.com',
|
|
apiKey: 'sk-test',
|
|
model: 'deepseek-chat',
|
|
maxTokens: 2222,
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe('https://api.deepseek.com/v1/chat/completions');
|
|
expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({
|
|
model: 'deepseek-chat',
|
|
max_tokens: 2222,
|
|
stream: true,
|
|
});
|
|
expect(JSON.parse(String(upstreamInit?.body))).not.toHaveProperty(
|
|
'max_completion_tokens',
|
|
);
|
|
});
|
|
|
|
it('keeps max_tokens for Azure gpt-4o deployment chat-completions payloads', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://resource.openai.azure.com',
|
|
apiKey: 'azure-key',
|
|
model: 'gpt-4o',
|
|
apiVersion: '',
|
|
maxTokens: 3333,
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 3333,
|
|
stream: true,
|
|
});
|
|
expect(JSON.parse(String(upstreamInit?.body))).not.toHaveProperty(
|
|
'max_completion_tokens',
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
['anthropic', 'https://api.anthropic.com/v1/messages'],
|
|
['openai', 'https://api.openai.com/v1/chat/completions'],
|
|
[
|
|
'azure',
|
|
'https://resource.openai.azure.com/openai/deployments/model-one/chat/completions?api-version=2024-10-21',
|
|
],
|
|
[
|
|
'google',
|
|
'https://generativelanguage.googleapis.com/v1beta/models/model-one:streamGenerateContent?alt=sse',
|
|
],
|
|
])('disables upstream redirects for %s proxy requests', async (provider, expectedUrl) => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
if (url === expectedUrl && init?.redirect === 'error') {
|
|
return Promise.reject(new TypeError('fetch failed: redirect blocked'));
|
|
}
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const requestBody: Record<string, unknown> = {
|
|
baseUrl:
|
|
provider === 'azure'
|
|
? 'https://resource.openai.azure.com'
|
|
: provider === 'google'
|
|
? 'https://generativelanguage.googleapis.com'
|
|
: provider === 'anthropic'
|
|
? 'https://api.anthropic.com'
|
|
: 'https://api.openai.com',
|
|
apiKey: `${provider}-key`,
|
|
model: 'model-one',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
};
|
|
if (provider === 'azure') requestBody.apiVersion = '2024-10-21';
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/${provider}/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
const text = await res.text();
|
|
expect(text).toContain('event: error');
|
|
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe(expectedUrl);
|
|
expect(upstreamInit?.redirect).toBe('error');
|
|
});
|
|
|
|
it('keeps the default Azure api-version for deployment URLs when the field is blank', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://resource.openai.azure.com',
|
|
apiKey: 'azure-key',
|
|
model: 'deployment-one',
|
|
apiVersion: '',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe(
|
|
'https://resource.openai.azure.com/openai/deployments/deployment-one/chat/completions?api-version=2024-10-21',
|
|
);
|
|
});
|
|
|
|
it('omits Azure api-version for OpenAI-compatible v1 paths when the field is blank', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://resource.services.ai.azure.com/api/projects/project/openai/v1',
|
|
apiKey: 'azure-key',
|
|
model: 'deployment-one',
|
|
apiVersion: '',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe(
|
|
'https://resource.services.ai.azure.com/api/projects/project/openai/v1/chat/completions',
|
|
);
|
|
expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({
|
|
model: 'deployment-one',
|
|
});
|
|
});
|
|
|
|
it('removes copied Azure api-version query params for OpenAI-compatible v1 paths when the field is blank', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/azure/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl:
|
|
'https://resource.services.ai.azure.com/api/projects/project/openai/v1?api-version=2024-10-21',
|
|
apiKey: 'azure-key',
|
|
model: 'deployment-one',
|
|
apiVersion: '',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe(
|
|
'https://resource.services.ai.azure.com/api/projects/project/openai/v1/chat/completions',
|
|
);
|
|
});
|
|
|
|
it('surfaces Gemini safety blocks as proxy errors', async () => {
|
|
vi.stubGlobal('fetch', vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: {"promptFeedback":{"blockReason":"SAFETY"}}\n\n'));
|
|
}));
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/google/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://generativelanguage.googleapis.com',
|
|
apiKey: 'google-key',
|
|
model: 'gemini-2.0-flash',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
await expect(res.text()).resolves.toContain('Gemini blocked the prompt (SAFETY).');
|
|
});
|
|
|
|
it('forwards maxTokens to Gemini generation config', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/google/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://generativelanguage.googleapis.com',
|
|
apiKey: 'google-key',
|
|
model: 'gemini-2.0-flash',
|
|
maxTokens: 1234,
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(upstreamInit?.redirect).toBe('error');
|
|
expect(JSON.parse(String(upstreamInit?.body))).toMatchObject({
|
|
generationConfig: { maxOutputTokens: 1234 },
|
|
});
|
|
});
|
|
|
|
it('normalizes Gemini model ids and base URLs in the streaming proxy', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/google/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
|
apiKey: 'google-key',
|
|
model: 'models/gemini-2.0-flash',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe(
|
|
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse',
|
|
);
|
|
expect(upstreamInit?.redirect).toBe('error');
|
|
});
|
|
|
|
// Regression for PR #1176: the Ollama proxy fetch must also set
|
|
// `redirect: 'error'`. Without it, a validated public host could
|
|
// 3xx the daemon to a private/internal URL and slip past the
|
|
// resolved-IP SSRF check that runs *before* the fetch.
|
|
it('forwards redirect:error on the Ollama proxy upstream fetch', async () => {
|
|
const ndjsonResponse = new Response(
|
|
new TextEncoder().encode('{"done":true}\n'),
|
|
{
|
|
status: 200,
|
|
headers: { 'content-type': 'application/x-ndjson' },
|
|
},
|
|
);
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(ndjsonResponse);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/ollama/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://ollama.example.com',
|
|
apiKey: 'ollama-key',
|
|
model: 'llama3',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
|
expect(String(upstreamUrl)).toBe('https://ollama.example.com/api/chat');
|
|
expect(upstreamInit?.redirect).toBe('error');
|
|
});
|
|
|
|
it('streams delta + end for SenseAudio chat completions', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse([
|
|
'data: {"choices":[{"delta":{"content":"sense"}}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n')));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
}),
|
|
});
|
|
|
|
await expect(res.text()).resolves.toContain('event: delta\ndata: {"delta":"sense"}');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.senseaudio.cn/v1/chat/completions',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer sa-test' }),
|
|
redirect: 'error',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('defaults SenseAudio base URL to api.senseaudio.cn when caller omits it', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
expect(String(fetchMock.mock.calls[0]![0])).toBe(
|
|
'https://api.senseaudio.cn/v1/chat/completions',
|
|
);
|
|
});
|
|
|
|
it('rejects SenseAudio requests that omit apiKey or model', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const missingKey = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
expect(missingKey.status).toBe(400);
|
|
|
|
const missingModel = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
apiKey: 'sa-test',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
expect(missingModel.status).toBe(400);
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('disables upstream redirects for senseaudio proxy requests', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse('data: [DONE]\n\n'));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'model-one',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
const upstreamCall = fetchMock.mock.calls.find(([input]) =>
|
|
!String(input).startsWith(baseUrl),
|
|
);
|
|
expect(upstreamCall).toBeDefined();
|
|
const upstreamInit = upstreamCall![1] as FetchInit;
|
|
expect(upstreamInit?.redirect).toBe('error');
|
|
});
|
|
|
|
it('injects generate_image tool definition on every SenseAudio request', async () => {
|
|
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
return Promise.resolve(sseResponse([
|
|
'data: {"choices":[{"delta":{"content":"ok"}}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n')));
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
const upstreamCall = fetchMock.mock.calls.find(([input]) =>
|
|
!String(input).startsWith(baseUrl),
|
|
);
|
|
expect(upstreamCall).toBeDefined();
|
|
const body = JSON.parse(String((upstreamCall![1] as FetchInit)?.body));
|
|
expect(body.tool_choice).toBe('auto');
|
|
expect(Array.isArray(body.tools)).toBe(true);
|
|
expect(body.tools[0]).toMatchObject({
|
|
type: 'function',
|
|
function: { name: 'generate_image' },
|
|
});
|
|
});
|
|
|
|
it('runs the BYOK image tool loop end-to-end', async () => {
|
|
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]);
|
|
const upstreamChatBodies: any[] = [];
|
|
let chatCallIndex = 0;
|
|
|
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
|
|
// SenseAudio image generation
|
|
if (url === 'https://api.senseaudio.cn/v1/image/sync') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
url: 'https://cdn.example.test/cat.png',
|
|
base_resp: { status_code: 0, status_msg: 'success' },
|
|
}),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
// Image bytes download (initiated by the tool, not via the proxy)
|
|
if (url === 'https://cdn.example.test/cat.png') {
|
|
return new Response(pngBytes, {
|
|
status: 200,
|
|
headers: { 'content-type': 'image/png' },
|
|
});
|
|
}
|
|
|
|
// Upstream chat completions — capture bodies, return different SSE per call
|
|
if (url === 'https://api.senseaudio.cn/v1/chat/completions') {
|
|
upstreamChatBodies.push(JSON.parse(String(init?.body || '{}')));
|
|
chatCallIndex++;
|
|
if (chatCallIndex === 1) {
|
|
// First turn: model decides to call generate_image
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"generate_image","arguments":"{\\"prompt\\":\\"a cat\\"}"}}]},"finish_reason":null}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
// Second turn: model summarises with image embedded in markdown
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"content":"Here is your cat: "}}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{"content":""}}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'draw a cat' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
|
|
// Final assistant text streams through to the client
|
|
expect(body).toContain('event: delta');
|
|
expect(body).toContain('Here is your cat');
|
|
expect(body).toContain('');
|
|
expect(body).toContain('event: end');
|
|
|
|
// Two upstream chat completions calls happened (loop ran exactly once)
|
|
expect(upstreamChatBodies).toHaveLength(2);
|
|
|
|
// Second upstream call includes assistant{tool_calls} + tool{result}
|
|
const secondMessages = upstreamChatBodies[1].messages;
|
|
expect(secondMessages).toHaveLength(3);
|
|
expect(secondMessages[0]).toEqual({ role: 'user', content: 'draw a cat' });
|
|
expect(secondMessages[1]).toMatchObject({
|
|
role: 'assistant',
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: 'call_abc',
|
|
type: 'function',
|
|
function: {
|
|
name: 'generate_image',
|
|
arguments: '{"prompt":"a cat"}',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
expect(secondMessages[2]).toMatchObject({
|
|
role: 'tool',
|
|
tool_call_id: 'call_abc',
|
|
content: expect.stringMatching(
|
|
/Image generated successfully\. URL: \/api\/projects\/test-project\/files\/byok-[a-z0-9-]+\.png/,
|
|
),
|
|
});
|
|
});
|
|
|
|
it('feeds a tool error message back to the model when generate_image fails', async () => {
|
|
const upstreamChatBodies: any[] = [];
|
|
let chatCallIndex = 0;
|
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
if (url === 'https://api.senseaudio.cn/v1/image/sync') {
|
|
return new Response(
|
|
JSON.stringify({ error_message: 'sensitive_content_blocked' }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === 'https://api.senseaudio.cn/v1/chat/completions') {
|
|
upstreamChatBodies.push(JSON.parse(String(init?.body || '{}')));
|
|
chatCallIndex++;
|
|
if (chatCallIndex === 1) {
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_err","type":"function","function":{"name":"generate_image","arguments":"{\\"prompt\\":\\"...\\"}"}}]},"finish_reason":null}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"content":"Sorry, that one was blocked."}}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'draw something blocked' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
expect(body).toContain('Sorry, that one was blocked');
|
|
|
|
expect(upstreamChatBodies).toHaveLength(2);
|
|
const toolMsg = upstreamChatBodies[1].messages[2];
|
|
expect(toolMsg.role).toBe('tool');
|
|
expect(toolMsg.tool_call_id).toBe('call_err');
|
|
expect(toolMsg.content).toMatch(/Image generation failed/);
|
|
expect(toolMsg.content).toMatch(/sensitive_content_blocked/);
|
|
});
|
|
|
|
it('feeds speech-specific tool error copy when generate_speech arguments are malformed', async () => {
|
|
const upstreamChatBodies: any[] = [];
|
|
let chatCallIndex = 0;
|
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
if (url === 'https://api.senseaudio.cn/v1/chat/completions') {
|
|
upstreamChatBodies.push(JSON.parse(String(init?.body || '{}')));
|
|
chatCallIndex++;
|
|
if (chatCallIndex === 1) {
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_speech_bad_args","type":"function","function":{"name":"generate_speech","arguments":"{\\"text\\":"}}]},"finish_reason":null}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"content":"I need a valid script before generating speech."}}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'make a voiceover' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
expect(body).toContain('I need a valid script before generating speech.');
|
|
|
|
expect(upstreamChatBodies).toHaveLength(2);
|
|
const toolMsg = upstreamChatBodies[1].messages[2];
|
|
expect(toolMsg.role).toBe('tool');
|
|
expect(toolMsg.tool_call_id).toBe('call_speech_bad_args');
|
|
expect(toolMsg.content).toMatch(/Speech generation failed/);
|
|
expect(toolMsg.content).toMatch(/tool arguments were not valid JSON/);
|
|
expect(toolMsg.content).not.toMatch(/Image generation failed/);
|
|
});
|
|
|
|
it('bounds the BYOK tool loop at MAX_BYOK_TOOL_LOOPS=3', async () => {
|
|
let chatCallIndex = 0;
|
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
if (url === 'https://api.senseaudio.cn/v1/image/sync') {
|
|
return new Response(
|
|
JSON.stringify({ url: 'https://cdn.example.test/x.png' }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === 'https://cdn.example.test/x.png') {
|
|
return new Response(Buffer.from([0x89, 0x50]), { status: 200 });
|
|
}
|
|
if (url === 'https://api.senseaudio.cn/v1/chat/completions') {
|
|
chatCallIndex++;
|
|
// Always return tool_calls — the model never returns text
|
|
return sseResponse([
|
|
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_${chatCallIndex}","type":"function","function":{"name":"generate_image","arguments":"{\\"prompt\\":\\"x\\"}"}}]},"finish_reason":null}]}`,
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'infinite' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
expect(body).toContain('event: end');
|
|
// Loop ran exactly MAX_BYOK_TOOL_LOOPS times before bailing.
|
|
expect(chatCallIndex).toBe(3);
|
|
});
|
|
|
|
it('writes the generated image into the project folder and serves it via /api/projects/:id/files/*', async () => {
|
|
const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x42, 0x59]);
|
|
let capturedUrl: string | undefined;
|
|
|
|
const fetchMock = vi.fn(async (input: FetchInput, init?: FetchInit) => {
|
|
const url = String(input);
|
|
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
|
if (url === 'https://api.senseaudio.cn/v1/image/sync') {
|
|
return new Response(
|
|
JSON.stringify({ url: 'https://cdn.example.test/served.png' }),
|
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
}
|
|
if (url === 'https://cdn.example.test/served.png') {
|
|
return new Response(pngBytes, { status: 200 });
|
|
}
|
|
if (url === 'https://api.senseaudio.cn/v1/chat/completions') {
|
|
const body = JSON.parse(String(init?.body || '{}'));
|
|
// Capture URL the tool produced from the second turn's tool message.
|
|
const toolMsg = body.messages?.find((m: any) => m.role === 'tool');
|
|
if (toolMsg) {
|
|
const match = /URL: (\/api\/projects\/[A-Za-z0-9._-]+\/files\/byok-[a-z0-9-]+\.png)/.exec(toolMsg.content);
|
|
if (match) capturedUrl = match[1];
|
|
}
|
|
const isToolTurn = !toolMsg;
|
|
if (isToolTurn) {
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_serve","type":"function","function":{"name":"generate_image","arguments":"{\\"prompt\\":\\"s\\"}"}}]},"finish_reason":null}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
return sseResponse([
|
|
'data: {"choices":[{"index":0,"delta":{"content":"done"}}]}',
|
|
'',
|
|
'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
'',
|
|
'data: [DONE]',
|
|
'',
|
|
].join('\n'));
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`);
|
|
});
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const proxyRes = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
apiKey: 'sa-test',
|
|
projectId: 'test-project',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'gen' }],
|
|
}),
|
|
});
|
|
// Drain the SSE body so the tool loop fully completes before we assert.
|
|
await proxyRes.text();
|
|
|
|
expect(capturedUrl).toBeDefined();
|
|
// The URL the tool emits is relative — same-origin via Next.js
|
|
// rewrite in production, hits this test server directly here.
|
|
// We GET the captured URL through the standard project file route
|
|
// and assert the bytes come back. This proves both halves:
|
|
// (1) the image landed in <projectsRoot>/<projectId>/ as expected
|
|
// (so listFiles / FileViewer / archive will find it), and
|
|
// (2) /api/projects/:id/files/* serves it without needing any
|
|
// byok-specific route.
|
|
const imgRes = await realFetch(`${baseUrl}${capturedUrl!}`);
|
|
expect(imgRes.status).toBe(200);
|
|
expect(imgRes.headers.get('content-type')).toMatch(/^image\/png/);
|
|
const served = Buffer.from(await imgRes.arrayBuffer());
|
|
expect(served.equals(pngBytes)).toBe(true);
|
|
});
|
|
|
|
it('rejects senseaudio chat requests without a projectId', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
apiKey: 'sa-test',
|
|
model: 'senseaudio-s2',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
// no projectId — should 400
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects senseaudio chat requests with an unsafe projectId', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const res = await realFetch(`${baseUrl}/api/proxy/senseaudio/stream`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
apiKey: 'sa-test',
|
|
model: 'senseaudio-s2',
|
|
projectId: '../etc/passwd',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
}),
|
|
});
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Plan §3.A4 / spec §11.8 (e2e-7): the API-fallback proxy paths must
|
|
// never carry plugin context. The web sidecar's fallback mode bypasses
|
|
// the daemon snapshot bus, so any pluginId / appliedPluginSnapshotId in
|
|
// the body short-circuits with 409 PLUGIN_REQUIRES_DAEMON. This is the
|
|
// behavioral anchor for e2e-7 and is exercised against every proxy entry.
|
|
describe('API fallback rejects plugin runs', () => {
|
|
const proxies = [
|
|
'/api/proxy/anthropic/stream',
|
|
'/api/proxy/openai/stream',
|
|
'/api/proxy/azure/stream',
|
|
'/api/proxy/google/stream',
|
|
'/api/proxy/senseaudio/stream',
|
|
];
|
|
|
|
for (const path of proxies) {
|
|
it(`rejects pluginId on ${path} with 409 PLUGIN_REQUIRES_DAEMON`, async () => {
|
|
const res = await realFetch(`${baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.example.com/v1',
|
|
apiKey: 'sk-test',
|
|
model: 'gpt-test',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
pluginId: 'sample-plugin',
|
|
}),
|
|
});
|
|
expect(res.status).toBe(409);
|
|
const body = (await res.json()) as { error?: { code?: string } };
|
|
expect(body?.error?.code).toBe('PLUGIN_REQUIRES_DAEMON');
|
|
});
|
|
|
|
it(`rejects appliedPluginSnapshotId on ${path}`, async () => {
|
|
const res = await realFetch(`${baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
baseUrl: 'https://api.example.com/v1',
|
|
apiKey: 'sk-test',
|
|
model: 'gpt-test',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
appliedPluginSnapshotId: '00000000-0000-0000-0000-000000000000',
|
|
}),
|
|
});
|
|
expect(res.status).toBe(409);
|
|
const body = (await res.json()) as { error?: { code?: string } };
|
|
expect(body?.error?.code).toBe('PLUGIN_REQUIRES_DAEMON');
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
function sseResponse(text: string): Response {
|
|
const encoder = new TextEncoder();
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode(text));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{
|
|
status: 200,
|
|
headers: { 'content-type': 'text/event-stream' },
|
|
},
|
|
);
|
|
}
|