mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents.
175 lines
5.0 KiB
TypeScript
175 lines
5.0 KiB
TypeScript
import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js";
|
|
import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js";
|
|
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
|
import { AiProviderError } from "./types.js";
|
|
|
|
/**
|
|
* Every outbound provider request goes through here so the egress rules cannot
|
|
* be bypassed by an adapter calling fetch directly.
|
|
*
|
|
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
|
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
|
* refuse them), so they use the installed Undici fetch implementation with
|
|
* its matching proxy dispatcher -- still respecting proxy configuration.
|
|
*/
|
|
export async function providerFetch(
|
|
url: string,
|
|
init: RequestInit,
|
|
): Promise<Response> {
|
|
const allowlist = await readPrivateAllowlist();
|
|
const decision = evaluateEgress(url, allowlist);
|
|
|
|
if (!decision.allowed) {
|
|
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
|
}
|
|
|
|
if (decision.isPrivate) {
|
|
return fetchWithProxy(url, init);
|
|
}
|
|
|
|
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
|
}
|
|
|
|
export function joinUrl(base: string, path: string): string {
|
|
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
}
|
|
|
|
/**
|
|
* Yields the data payload of each SSE frame. Providers differ in what they put
|
|
* inside, so parsing the JSON is left to the caller.
|
|
*/
|
|
export async function* readSseLines(
|
|
response: Response,
|
|
): AsyncGenerator<string> {
|
|
const body = response.body;
|
|
if (!body) return;
|
|
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let newlineIndex = buffer.indexOf("\n");
|
|
while (newlineIndex !== -1) {
|
|
const line = buffer.slice(0, newlineIndex).trim();
|
|
buffer = buffer.slice(newlineIndex + 1);
|
|
if (line.startsWith("data:")) {
|
|
yield line.slice(5).trim();
|
|
}
|
|
newlineIndex = buffer.indexOf("\n");
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
/** Yields one parsed JSON object per line, for newline-delimited streams. */
|
|
export async function* readJsonLines(
|
|
response: Response,
|
|
): AsyncGenerator<unknown> {
|
|
const body = response.body;
|
|
if (!body) return;
|
|
|
|
const reader = body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
let newlineIndex = buffer.indexOf("\n");
|
|
while (newlineIndex !== -1) {
|
|
const line = buffer.slice(0, newlineIndex).trim();
|
|
buffer = buffer.slice(newlineIndex + 1);
|
|
if (line) {
|
|
try {
|
|
yield JSON.parse(line);
|
|
} catch {
|
|
// A partial or malformed frame is skipped rather than failing the
|
|
// whole stream.
|
|
}
|
|
}
|
|
newlineIndex = buffer.indexOf("\n");
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Digs the human-readable message out of an error body.
|
|
*
|
|
* Every provider nests it differently, and dumping the raw JSON produced
|
|
* something that got cut off mid-sentence. Falls back to a trimmed snippet
|
|
* when the shape is unfamiliar.
|
|
*/
|
|
function extractProviderMessage(body: string): string {
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
const message =
|
|
parsed?.error?.message ??
|
|
parsed?.error?.["message"] ??
|
|
parsed?.message ??
|
|
(typeof parsed?.error === "string" ? parsed.error : null);
|
|
if (typeof message === "string" && message.trim()) {
|
|
return message.trim();
|
|
}
|
|
} catch {
|
|
// Not JSON; fall through to the snippet.
|
|
}
|
|
|
|
const trimmed = body.trim();
|
|
if (!trimmed) return "";
|
|
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
|
|
}
|
|
|
|
export async function assertOk(
|
|
response: Response,
|
|
provider: string,
|
|
): Promise<void> {
|
|
if (response.ok) return;
|
|
|
|
let body = "";
|
|
try {
|
|
body = await response.text();
|
|
} catch {
|
|
body = "";
|
|
}
|
|
|
|
const detail = extractProviderMessage(body);
|
|
|
|
// Rate limits and auth failures are the two the user can actually act on,
|
|
// so they say what to do instead of reading like an internal failure.
|
|
if (response.status === 429) {
|
|
throw new AiProviderError(
|
|
`${provider} rate limit reached. Wait a moment and try again, or check your plan's quota.${
|
|
detail ? ` (${detail})` : ""
|
|
}`,
|
|
429,
|
|
);
|
|
}
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new AiProviderError(
|
|
`${provider} rejected the API key. Check that it is correct and still active.${
|
|
detail ? ` (${detail})` : ""
|
|
}`,
|
|
response.status,
|
|
);
|
|
}
|
|
|
|
throw new AiProviderError(
|
|
`${provider} request failed (${response.status})${detail ? `: ${detail}` : ""}`,
|
|
response.status,
|
|
);
|
|
}
|