open-design/apps/daemon/tests/proxy-routes.test.ts
bougie-atxp d28acdc879
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 1s
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
Fix Gemini BYOK model URL normalization (#2761)
Co-authored-by: ATXP Earn Clowdbot <bougie-atxp@users.noreply.github.com>
2026-05-24 03:23:36 +00:00

1369 lines
50 KiB
TypeScript

import type http from 'node:http';
import { afterEach, beforeAll, afterAll, describe, expect, it, vi } from 'vitest';
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;
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();
});
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',
}),
);
});
// 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":"![cat](generated)"}}]}',
'',
'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('![cat](generated)');
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' },
},
);
}