mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
v3.8.40 cycle integration → main. All test gates green (Unit/Integration/Coverage/Node-compat/Quality-Ratchet). The only red check, 'PR Test Policy', is the test-masking heuristic firing on the cumulative ~57-commit release diff (legitimate assert consolidations already reviewed per-PR — Gemini CLI removal #5246, retired GPT models #5280, provider catalog refreshes); overridden with --admin per the documented release-PR convention. CodeQL/SonarQube advisory scans non-blocking; #5278's code already passed CodeQL on main. Homologated on VPS 192.168.0.15 (v3.8.40 healthy).
251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
/**
|
|
* PhindExecutor — Free Dev-Focused AI Chat via phind.com
|
|
*
|
|
* Routes requests through Phind's chat API.
|
|
* Free tier available. Uses session cookie for auth.
|
|
*
|
|
* Endpoint: POST https://www.phind.com/api/agent
|
|
* Auth: Session cookie from phind.com
|
|
* SSE response with data: prefixed JSON chunks (OpenAI-compatible delta format)
|
|
*/
|
|
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
|
import { makeExecutorErrorResult as makeErrorResult, normalizeCookie } from "../utils/error.ts";
|
|
|
|
const BASE_URL = "https://www.phind.com";
|
|
const CHAT_URL = `${BASE_URL}/api/agent`;
|
|
const USER_AGENT =
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
|
|
|
export class PhindExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("phind", { id: "phind", baseUrl: "https://www.phind.com" });
|
|
}
|
|
|
|
async execute(input: ExecuteInput) {
|
|
const { body, credentials, signal, stream: wantStream } = input;
|
|
const bodyObj = (body || {}) as Record<string, unknown>;
|
|
const rawCookie = String(credentials?.apiKey ?? "").trim();
|
|
const cookie = normalizeCookie(rawCookie);
|
|
|
|
// Build Phind-format messages
|
|
const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || [];
|
|
const phindMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
|
const modelId = (bodyObj.model as string) || "phind-model";
|
|
// Last user message as userInput (Phind expects this field)
|
|
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
|
const userInput = lastUserMsg?.content || "";
|
|
|
|
const reqBody = {
|
|
userInput,
|
|
messages: phindMessages,
|
|
requestedModel: modelId,
|
|
webSearchMode: "auto",
|
|
isChromeExtension: false,
|
|
language: "en-US",
|
|
date: new Date().toISOString(),
|
|
};
|
|
|
|
const reqHeaders: Record<string, string> = {
|
|
"Content-Type": "application/json;charset=UTF-8",
|
|
"User-Agent": USER_AGENT,
|
|
Accept: "text/event-stream",
|
|
Referer: `${BASE_URL}/`,
|
|
Origin: BASE_URL,
|
|
"Sec-Fetch-Dest": "empty",
|
|
"Sec-Fetch-Mode": "cors",
|
|
"Sec-Fetch-Site": "same-origin",
|
|
};
|
|
if (cookie) reqHeaders.Cookie = cookie;
|
|
|
|
let upstream: Response;
|
|
try {
|
|
upstream = await fetch(CHAT_URL, {
|
|
method: "POST",
|
|
headers: reqHeaders,
|
|
body: JSON.stringify(reqBody),
|
|
signal,
|
|
});
|
|
} catch (err) {
|
|
return makeErrorResult(
|
|
502,
|
|
`Phind fetch failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
body,
|
|
CHAT_URL
|
|
);
|
|
}
|
|
|
|
if (!upstream.ok) {
|
|
const errText = await upstream.text().catch(() => "");
|
|
return makeErrorResult(upstream.status, `Phind error: ${errText}`, body, CHAT_URL);
|
|
}
|
|
|
|
// Phind always returns SSE — parse it for both streaming and non-streaming
|
|
if (!upstream.body) {
|
|
return makeErrorResult(502, "Phind returned empty response body", body, CHAT_URL);
|
|
}
|
|
|
|
if (!wantStream) {
|
|
// Collect all SSE chunks into a single response
|
|
const reader = upstream.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let fullText = "";
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data:")) continue;
|
|
const data = line.slice(5).trim();
|
|
if (data === "[DONE]") continue;
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const text = parsed.choices?.[0]?.delta?.content || parsed.content || "";
|
|
if (text) fullText += text;
|
|
} catch {
|
|
// Skip unparseable chunks
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!signal?.aborted) {
|
|
return makeErrorResult(
|
|
502,
|
|
`Phind stream read failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
body,
|
|
CHAT_URL
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
response: new Response(
|
|
JSON.stringify({
|
|
id: `chatcmpl-ph-${Date.now()}`,
|
|
object: "chat.completion",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: modelId,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: "assistant", content: fullText },
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: Math.ceil((userInput || "").length / 4),
|
|
completion_tokens: Math.ceil(fullText.length / 4),
|
|
total_tokens: Math.ceil(((userInput || "").length + fullText.length) / 4),
|
|
},
|
|
}),
|
|
{ headers: { "Content-Type": "application/json" } }
|
|
),
|
|
url: CHAT_URL,
|
|
headers: reqHeaders,
|
|
transformedBody: reqBody,
|
|
};
|
|
}
|
|
|
|
// Streaming: transform Phind SSE to OpenAI format
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
const sseStream = new ReadableStream({
|
|
async start(controller) {
|
|
const reader = upstream.body!.getReader();
|
|
let buffer = "";
|
|
try {
|
|
// Initial role chunk
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({
|
|
id: `chatcmpl-ph-${Date.now()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: modelId,
|
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
|
})}\n\n`
|
|
)
|
|
);
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith("data:")) continue;
|
|
const data = line.slice(5).trim();
|
|
if (data === "[DONE]") {
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
const text = parsed.choices?.[0]?.delta?.content || parsed.content || "";
|
|
if (text) {
|
|
const chunk = {
|
|
id: `chatcmpl-ph-${Date.now()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: modelId,
|
|
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
|
};
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
|
|
}
|
|
} catch {
|
|
// Skip unparseable chunks
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (!signal?.aborted) {
|
|
controller.enqueue(
|
|
encoder.encode(
|
|
`data: ${JSON.stringify({
|
|
id: `chatcmpl-ph-${Date.now()}`,
|
|
object: "chat.completion.chunk",
|
|
created: Math.floor(Date.now() / 1000),
|
|
model: modelId,
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
content: `[Stream error: ${err instanceof Error ? err.message : String(err)}]`,
|
|
},
|
|
finish_reason: "stop",
|
|
},
|
|
],
|
|
})}\n\n`
|
|
)
|
|
);
|
|
}
|
|
} finally {
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
|
|
return {
|
|
response: new Response(sseStream, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
}),
|
|
url: CHAT_URL,
|
|
headers: reqHeaders,
|
|
transformedBody: reqBody,
|
|
};
|
|
}
|
|
}
|