Files
2026-07-13 13:39:12 +08:00

355 lines
11 KiB
TypeScript

import { BaseExecutor, type ExecuteInput } from "./base.ts";
import type { ProviderCredentials } from "./base.ts";
const API_BASE = "https://theoldllm.vercel.app";
const API_PATH = "/api/chatgpt";
const API_URL = `${API_BASE}${API_PATH}`;
const CHROME_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
// ── Model name mapping ────────────────────────────────────────────────────
const GPT_MODELS: Record<string, string> = {
"gpt-5.4": "GPT_5_4",
"gpt-5.3": "GPT_5_3",
"gpt-5.2": "GPT_5_2",
"gpt-5.1": "GPT_5_1",
"gpt-5": "GPT_5",
gpt5_4: "GPT_5_4",
gpt5_3: "GPT_5_3",
gpt5_2: "GPT_5_2",
gpt5_1: "GPT_5_1",
gpt_4o: "GPT_4O",
"gpt-4o": "GPT_4O",
gpt_5_3: "GPT_5_3",
gpt_5_2: "GPT_5_2",
gpt_5_1: "GPT_5_1",
gpt_5: "GPT_5",
};
const CLAUDE_NAMES: Record<string, string> = {
"claude-4.6-opus": "CLAUDE_4_6_OPUS",
"claude-4.6-sonnet": "CLAUDE_4_6_SONNET",
"claude-4.5-haiku": "CLAUDE_4_5_HAIKU",
claude_opus_4: "CLAUDE_4_6_OPUS",
claude_sonnet_4: "CLAUDE_4_6_SONNET",
claude_haiku_3_5: "CLAUDE_4_5_HAIKU",
"claude opus 4": "CLAUDE_4_6_OPUS",
"claude sonnet 4": "CLAUDE_4_6_SONNET",
"claude haiku 3.5": "CLAUDE_4_5_HAIKU",
};
// Canonical upstream model IDs served by theoldllm's /api/chatgpt proxy
// (apiProvider "chatgpt" in the site's model catalog — the free, reachable tier).
// Source: https://theoldllm.vercel.app model list (reported in #5181).
// These pass through mapModel() UNCHANGED — critical for non-GPT/Claude models
// (Gemini, o-series, Grok, DeepSeek, Sonar) which would otherwise fall through
// to the GPT_5_4 default and silently misroute.
export const CHATGPT_UPSTREAM_MODELS: ReadonlySet<string> = new Set<string>([
"GPT_5_4",
"GPT_5_3",
"GPT_5_2",
"GPT_5_1",
"GPT_5",
"GPT_o4_mini",
"GPT_o3_mini",
"gemini_3_pro",
"gemini_2_5_pro",
"gemini_2_0_flash",
"gemini_1_5_flash",
"CLAUDE_4_6_OPUS",
"CLAUDE_4_6_SONNET",
"CLAUDE_4_5_HAIKU",
"openrouter_gpt_4_o",
"openrouter_gpt_4_o_mini",
"openrouter_gpt_4",
"openrouter_grok_4",
"together_deepseek_r1",
"openrouter_deepseek_r1",
"together_deepseek_v3",
"openrouter_deepseek_v3",
"sonar-deep-research",
"sonar-pro",
"openrouter_web_search",
]);
export function mapModel(model: string): string {
const trimmed = model.trim();
// Known upstream IDs (from live discovery / refreshed catalog) route as-is.
if (CHATGPT_UPSTREAM_MODELS.has(trimmed)) return trimmed;
const n = model.toLowerCase().trim();
const gptKey = n.replace(/[_\s]+/g, "-");
if (GPT_MODELS[gptKey]) return GPT_MODELS[gptKey];
const gptKey2 = n.replace(/[-\s]+/g, "_");
if (GPT_MODELS[gptKey2]) return GPT_MODELS[gptKey2];
if (CLAUDE_NAMES[n]) return CLAUDE_NAMES[n];
if (n.includes("claude")) {
if (n.includes("opus")) return "CLAUDE_4_6_OPUS";
if (n.includes("sonnet")) return "CLAUDE_4_6_SONNET";
if (n.includes("haiku")) return "CLAUDE_4_5_HAIKU";
}
if (n.includes("gpt") && n.includes("5")) return "GPT_5_4";
return "GPT_5_4";
}
// ── Token generation (mirrors client-side rie() from theoldllm.vercel.app) ──
//
// The SPA generates X-Request-Token via:
// const nie = "oldllm-client-2026";
// const n = Date.now();
// const e = `${n}-${nie}-${navigator.userAgent.slice(0, 20)}`;
// let t = djb2_hash(e);
// const r = crypto.randomUUID().slice(0, 8);
// return `${n.toString(36)}-${Math.abs(t).toString(36)}-${r}`;
//
// Since nie is a static constant and the UA prefix is known, we can generate
// valid tokens server-side without launching a browser.
const TOKEN_SEED = "oldllm-client-2026";
const UA_PREFIX = CHROME_UA.slice(0, 20); // "Mozilla/5.0 (Windows"
export function generateRequestToken(): string {
const n = Date.now();
const e = `${n}-${TOKEN_SEED}-${UA_PREFIX}`;
let t = 0;
for (let i = 0; i < e.length; i++) {
const s = e.charCodeAt(i);
t = (t << 5) - t + s;
t = t & t;
}
const r = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
return `${n.toString(36)}-${Math.abs(t).toString(36)}-${r}`;
}
// Exported for test compatibility — the new server-side token flow generates
// tokens per-request; this stub satisfies imports that set tokenCache.value.
export const tokenCache: { value: string; expiresAt: number } = { value: "", expiresAt: 0 };
// ── Direct Node.js fetch ──────────────────────────────────────────────────
async function directFetch(
reqBody: Record<string, unknown>,
signal?: AbortSignal | null
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);
const onSignal = signal ? () => controller.abort(signal.reason) : undefined;
signal?.addEventListener("abort", onSignal!, { once: true });
try {
return await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Client-Version": "3.8.4",
"X-Request-Token": generateRequestToken(),
"User-Agent": CHROME_UA,
},
body: JSON.stringify(reqBody),
signal: controller.signal,
});
} finally {
clearTimeout(timer);
if (onSignal) signal?.removeEventListener("abort", onSignal);
}
}
function isTokenRejected(status: number, body: string): boolean {
if (status === 401 || status === 403) return true;
try {
const p = JSON.parse(body);
return (
p?.error?.type === "access_denied" ||
(typeof p?.error === "string" && /blocked|denied|invalid/i.test(p.error))
);
} catch {
return false;
}
}
// ── SSE helpers ───────────────────────────────────────────────────────────
function parseSseContent(sseText: string): string {
let content = "";
for (const line of sseText.split("\n")) {
if (line.startsWith("data: ") && line !== "data: [DONE]") {
try {
const d = JSON.parse(line.slice(6));
content += d.choices?.[0]?.delta?.content || d.choices?.[0]?.delta?.text || "";
} catch {}
}
}
return content;
}
function buildChatCompletion(content: string, model: string): string {
return JSON.stringify({
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: mapModel(model),
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
function buildErrorResponse(status: number, body: string): string {
let detail = body;
for (const line of body.split("\n")) {
if (line.startsWith("data: ") && line !== "data: [DONE]") {
try {
const p = JSON.parse(line.slice(6));
if (p.error) {
detail = JSON.stringify(p.error);
break;
}
} catch {}
}
}
return JSON.stringify({
error: { message: detail, type: "upstream_error", code: `HTTP_${status}` },
});
}
// ── Executor ──────────────────────────────────────────────────────────────
export class TheOldLlmExecutor extends BaseExecutor {
constructor() {
super("theoldllm", { format: "openai" });
}
buildUrl(_model: string, _stream: boolean): string {
return API_URL;
}
buildHeaders(_credentials: ProviderCredentials): Record<string, string> {
return {
"Content-Type": "application/json",
"X-Client-Version": "3.8.4",
"User-Agent": CHROME_UA,
};
}
transformRequest(model: string, body: unknown, _stream: boolean): unknown {
if (typeof body === "object" && body !== null) {
return { ...(body as Record<string, unknown>), model: mapModel(model) };
}
return body;
}
async testConnection(
_credentials: ProviderCredentials,
_signal?: AbortSignal | null,
log?: ExecuteInput["log"]
): Promise<boolean> {
try {
const resp = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Client-Version": "3.8.4",
"X-Request-Token": generateRequestToken(),
"User-Agent": CHROME_UA,
},
body: JSON.stringify({
model: "GPT_5_4",
messages: [{ role: "user", content: "ping" }],
stream: false,
}),
signal: _signal ?? undefined,
});
return resp.status === 200;
} catch {
log?.warn?.("THEOLDLLM", "testConnection network error");
return false;
}
}
async execute(input: ExecuteInput): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}> {
const { model, stream, body, signal, log } = input;
const encoder = new TextEncoder();
if (signal?.aborted) {
return {
response: new Response(
encoder.encode(
JSON.stringify({
error: { message: "Request aborted", type: "abort", code: "ABORTED" },
})
),
{ status: 499, headers: { "Content-Type": "application/json" } }
),
url: API_URL,
headers: this.buildHeaders(input.credentials),
transformedBody: body,
};
}
try {
const reqBody = {
...(body as Record<string, unknown>),
model: mapModel(model),
stream: true,
};
let upstream = await directFetch(reqBody, signal);
let finalBody = await upstream.text();
if (isTokenRejected(upstream.status, finalBody)) {
log?.warn?.("THEOLDLLM", `Token rejected (${upstream.status}), retrying with fresh token…`);
upstream = await directFetch(reqBody, signal);
finalBody = await upstream.text();
}
if (upstream.status === 200 && finalBody) {
const payload = stream ? finalBody : buildChatCompletion(parseSseContent(finalBody), model);
return {
response: new Response(encoder.encode(payload), {
status: 200,
headers: {
"Content-Type": stream ? "text/event-stream" : "application/json",
"Cache-Control": "no-cache",
},
}),
url: API_URL,
headers: this.buildHeaders(input.credentials),
transformedBody: body,
};
}
return {
response: new Response(encoder.encode(buildErrorResponse(upstream.status, finalBody)), {
status: upstream.status,
headers: { "Content-Type": "application/json" },
}),
url: API_URL,
headers: this.buildHeaders(input.credentials),
transformedBody: body,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log?.error?.("THEOLDLLM", `Executor error: ${msg}`);
return {
response: new Response(
encoder.encode(
JSON.stringify({
error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" },
})
),
{ status: 502, headers: { "Content-Type": "application/json" } }
),
url: API_URL,
headers: this.buildHeaders(input.credentials),
transformedBody: body,
};
}
}
}
export default TheOldLlmExecutor;