- Create src/lib/ai-proxy.ts: routes OpenAI chat completions requests to backend providers (opencode.ai, surfsense.com, deep-seek.ai) based on model name with request/response translation - Add POST /v1/chat/completions route to server with streaming (SSE) support, CORS, and proxy pool fallback on failure - Add GET /v1/models route returning available model list - Direct-first strategy: try backend directly, fall back to proxy pool on network failure (consistent with relay behavior) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
475 lines
14 KiB
TypeScript
475 lines
14 KiB
TypeScript
/**
|
|
* OpenAI-compatible AI proxy.
|
|
*
|
|
* Accepts requests in OpenAI Chat Completions format (POST /v1/chat/completions)
|
|
* and routes them to various backend AI providers based on the model name.
|
|
*
|
|
* Supported backends:
|
|
* - opencode.ai (OpenAI-compatible — passthrough)
|
|
* - surfsense.com (custom format — adapted)
|
|
* - deep-seek.ai (custom format — adapted)
|
|
*
|
|
* Streaming (SSE) is supported for all backends.
|
|
*/
|
|
|
|
import type { ProxyPool } from "./proxy-pool";
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────────
|
|
|
|
export interface OpenAIRequest {
|
|
model: string;
|
|
messages: Array<{ role: string; content: string }>;
|
|
temperature?: number;
|
|
max_tokens?: number;
|
|
top_p?: number;
|
|
stream?: boolean;
|
|
stop?: string | string[];
|
|
presence_penalty?: number;
|
|
frequency_penalty?: number;
|
|
}
|
|
|
|
export interface BackendConfig {
|
|
provider: string;
|
|
url: string;
|
|
method?: "POST" | "GET";
|
|
/** Extra headers to include (origin, referer, etc.) */
|
|
headers: Record<string, string>;
|
|
/** Field name for the model in the backend request (default: "model") */
|
|
modelField?: string;
|
|
/** Transform the OpenAI request into the backend format */
|
|
adaptRequest?: (req: OpenAIRequest) => unknown;
|
|
/** Transform a backend JSON response into OpenAI format */
|
|
adaptResponse?: (raw: unknown, req: OpenAIRequest) => unknown;
|
|
/** Transform a backend SSE/stream line into OpenAI SSE line (or null to skip) */
|
|
adaptStreamLine?: (line: string, req: OpenAIRequest) => string | null;
|
|
}
|
|
|
|
// ─── Model routing table ─────────────────────────────────────────────────────────
|
|
|
|
const MODEL_ROUTES: Record<string, BackendConfig> = {
|
|
// ── opencode.ai (OpenAI-compatible — passthrough) ────────────────
|
|
"deepseek-v4-flash-free": {
|
|
provider: "opencode",
|
|
url: "https://opencode.ai/zen/v1/chat/completions",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
},
|
|
|
|
// ── surfsense.com (custom format) ───────────────────────────────
|
|
"gpt-5.4-mini-no-login": {
|
|
provider: "surfsense",
|
|
url: "https://api.surfsense.com/api/v1/public/anon-chat/stream",
|
|
modelField: "model_slug",
|
|
headers: {
|
|
accept: "*/*",
|
|
"accept-language": "en-US,en;q=0.7",
|
|
"content-type": "application/json",
|
|
origin: "https://www.surfsense.com",
|
|
referer: "https://www.surfsense.com/",
|
|
"user-agent":
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
|
},
|
|
adaptRequest: (req) => ({
|
|
model_slug: req.model,
|
|
messages: req.messages,
|
|
}),
|
|
adaptStreamLine: (line) => {
|
|
// surfsense returns lines like: data: {"content":"Hello","done":false}
|
|
if (!line.startsWith("data: ")) return null;
|
|
try {
|
|
const raw = JSON.parse(line.slice(6));
|
|
if (raw.done) return "data: [DONE]";
|
|
return `data: ${JSON.stringify({
|
|
id: `chatcmpl-${Date.now()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: "gpt-5.4-mini-no-login",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: { content: raw.content ?? "" },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
})}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
adaptResponse: (raw: any) => ({
|
|
id: `chatcmpl-${Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: "gpt-5.4-mini-no-login",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: "assistant",
|
|
content: raw.content ?? raw.text ?? "",
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
}),
|
|
},
|
|
|
|
// ── deep-seek.ai (custom format) ────────────────────────────────
|
|
"deepseek/deepseek-v4-flash": {
|
|
provider: "deepseek",
|
|
url: "https://deep-seek.ai/api/chat",
|
|
headers: {
|
|
Accept: "*/*",
|
|
"Accept-Language": "en-US,en;q=0.8",
|
|
"Content-Type": "application/json",
|
|
Origin: "https://deep-seek.ai",
|
|
Referer: "https://deep-seek.ai/chat",
|
|
"User-Agent":
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
|
},
|
|
adaptStreamLine: (line) => {
|
|
// deep-seek.ai may return plain text chunks or SSE-like data
|
|
if (!line || line.trim().length === 0) return null;
|
|
// If it's already SSE format, try to pass through
|
|
if (line.startsWith("data: ")) {
|
|
// Rewrite the id and object fields
|
|
try {
|
|
const parsed = JSON.parse(line.slice(6));
|
|
parsed.id = `chatcmpl-${Date.now()}`;
|
|
parsed.object = "chat.completion.chunk";
|
|
parsed.created = Math.floor(Date.now() / 1000);
|
|
parsed.model = "deepseek/deepseek-v4-flash";
|
|
return `data: ${JSON.stringify(parsed)}`;
|
|
} catch {
|
|
return line;
|
|
}
|
|
}
|
|
// Plain text chunks — wrap in OpenAI SSE format
|
|
return `data: ${JSON.stringify({
|
|
id: `chatcmpl-${Date.now()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: "deepseek/deepseek-v4-flash",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: { content: line },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
})}`;
|
|
},
|
|
adaptResponse: (raw: any) => ({
|
|
id: `chatcmpl-${Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: "deepseek/deepseek-v4-flash",
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: {
|
|
role: "assistant",
|
|
content: raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "",
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
}),
|
|
},
|
|
};
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────────
|
|
|
|
/** List all available model names. */
|
|
export function listModels(): string[] {
|
|
return Object.keys(MODEL_ROUTES);
|
|
}
|
|
|
|
/** Look up a backend config by model name. */
|
|
export function resolveModel(model: string): BackendConfig | undefined {
|
|
return MODEL_ROUTES[model];
|
|
}
|
|
|
|
// ─── Request building ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Build the backend `fetch()` options from an OpenAI-style request.
|
|
*/
|
|
function buildBackendRequest(
|
|
req: OpenAIRequest,
|
|
config: BackendConfig,
|
|
proxyPool?: ProxyPool,
|
|
): { url: string; init: RequestInit & { proxy?: string } } {
|
|
const body =
|
|
config.adaptRequest?.(req) ?? {
|
|
model: req.model,
|
|
messages: req.messages,
|
|
temperature: req.temperature,
|
|
max_tokens: req.max_tokens,
|
|
top_p: req.top_p,
|
|
stream: req.stream,
|
|
stop: req.stop,
|
|
};
|
|
|
|
const init: RequestInit & { proxy?: string } = {
|
|
method: config.method ?? "POST",
|
|
headers: config.headers,
|
|
body: JSON.stringify(body),
|
|
};
|
|
|
|
// Direct first, proxy as fallback (if pool available)
|
|
if (proxyPool && proxyPool.size > 0) {
|
|
init.proxy = undefined; // start direct
|
|
}
|
|
|
|
return { url: config.url, init };
|
|
}
|
|
|
|
// ─── Response parsing ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Try to parse a JSON response into OpenAI format.
|
|
* Falls back to a generic wrapper if the adapter is unavailable.
|
|
*/
|
|
function parseJSONResponse(
|
|
text: string,
|
|
config: BackendConfig,
|
|
req: OpenAIRequest,
|
|
): unknown {
|
|
if (config.adaptResponse) {
|
|
try {
|
|
const raw = JSON.parse(text);
|
|
return config.adaptResponse(raw, req);
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
// Default fallback — assume raw text is the content
|
|
return {
|
|
id: `chatcmpl-${Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: req.model,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: text },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
};
|
|
}
|
|
|
|
// ─── Main handler ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Handle an OpenAI-compatible chat completions request.
|
|
*
|
|
* @param body Parsed JSON body (OpenAI format)
|
|
* @param proxyPool Optional proxy pool for fallback on failure
|
|
*/
|
|
export async function handleChatCompletion(
|
|
body: unknown,
|
|
proxyPool?: ProxyPool,
|
|
): Promise<Response> {
|
|
const req = body as OpenAIRequest;
|
|
|
|
if (!req.model) {
|
|
return new Response(
|
|
JSON.stringify({ error: { message: "model is required", type: "invalid_request_error" } }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
|
|
const config = resolveModel(req.model);
|
|
if (!config) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: `Unknown model: ${req.model}. Available: ${listModels().join(", ")}`,
|
|
type: "invalid_request_error",
|
|
},
|
|
}),
|
|
{
|
|
status: 400,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
const wantsStream = req.stream === true;
|
|
const { url, init } = buildBackendRequest(req, config, proxyPool);
|
|
|
|
// ── Execute (direct → proxy fallback) ─────────────────────────
|
|
let response: Response | undefined;
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
if (attempt === 0) {
|
|
init.proxy = undefined; // direct
|
|
} else if (attempt === 1 && proxyPool && proxyPool.size > 0) {
|
|
init.proxy = proxyPool.getProxyUrl()!;
|
|
} else if (attempt >= 2 && proxyPool && proxyPool.size > 0) {
|
|
const next = proxyPool.markFailed();
|
|
if (!next) break;
|
|
init.proxy = proxyPool.getProxyUrl()!;
|
|
} else {
|
|
break;
|
|
}
|
|
|
|
try {
|
|
response = await fetch(url, init);
|
|
if (response.ok && proxyPool && proxyPool.size > 0 && init.proxy && attempt > 0) {
|
|
proxyPool.markSuccess();
|
|
}
|
|
if (response.ok || response.status < 500) break; // non-5xx = no retry
|
|
} catch {
|
|
// fall through to next attempt
|
|
}
|
|
}
|
|
|
|
if (!response) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: { message: "Upstream service unreachable after retries", type: "server_error" },
|
|
}),
|
|
{ status: 502, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } },
|
|
);
|
|
}
|
|
|
|
// ── Handle error responses from backend ───────────────────────
|
|
if (!response.ok) {
|
|
const errBody = await response.text().catch(() => "");
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: `Upstream error ${response.status}: ${errBody.slice(0, 500)}`,
|
|
type: "upstream_error",
|
|
},
|
|
}),
|
|
{
|
|
status: response.status,
|
|
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Handle streaming ─────────────────────────────────────────
|
|
if (wantsStream || isStreamableResponse(response)) {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
const isNativeStream = contentType.includes("text/event-stream");
|
|
|
|
if (isNativeStream && config.provider === "opencode") {
|
|
// Passthrough for OpenAI-compatible SSE
|
|
return new Response(response.body, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Transform the stream
|
|
const transformed = transformStream(
|
|
response.body!,
|
|
config,
|
|
req,
|
|
);
|
|
return new Response(transformed, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── Handle non-streaming response ─────────────────────────────
|
|
const text = await response.text();
|
|
const adapted = parseJSONResponse(text, config, req);
|
|
|
|
return new Response(JSON.stringify(adapted), {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Stream handling ────────────────────────────────────────────────────────────
|
|
|
|
function isStreamableResponse(res: Response): boolean {
|
|
const ct = res.headers.get("content-type") ?? "";
|
|
return (
|
|
ct.includes("text/event-stream") ||
|
|
ct.includes("application/x-ndjson") ||
|
|
ct.includes("text/plain")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Transform a backend ReadableStream into OpenAI SSE format.
|
|
* Uses the config's `adaptStreamLine` if available.
|
|
*/
|
|
function transformStream(
|
|
body: ReadableStream,
|
|
config: BackendConfig,
|
|
req: OpenAIRequest,
|
|
): ReadableStream {
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
|
|
return new ReadableStream({
|
|
async pull(controller) {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
controller.close();
|
|
return;
|
|
}
|
|
|
|
const chunk = decoder.decode(value, { stream: true });
|
|
const lines = chunk.split("\n");
|
|
|
|
for (const line of lines) {
|
|
if (config.adaptStreamLine) {
|
|
const adapted = config.adaptStreamLine(line, req);
|
|
if (adapted) {
|
|
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
|
}
|
|
} else {
|
|
// Default passthrough
|
|
controller.enqueue(encoder.encode(line + "\n\n"));
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({ error: String(err) })}\n\n`,
|
|
),
|
|
);
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
}
|