mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Adds felo-web, a free no-signup no-API-key chat/search-agent aggregator
(felo.ai), following the same architectural pattern as the existing
duckduckgo-web/blackbox-web "-web" scrape family:
- POST /api-proxy/main/search/threads opens a search thread and returns a
stream_key.
- GET /api/message/v1/stream/{stream_key} streams Felo's bespoke
data:{...}-line SSE, translated into OpenAI-compatible chunks.
- 5 models (felo-chat/search/scholar/social/document) map to Felo's
chat/google/scholar/social/document search categories.
Registered in providers.ts (noauth.ts, no-auth like duckduckgo-web),
providerRegistry.ts, and executors/index.ts. Free-tier catalog entries
added with tos: "avoid" (reverse-engineered endpoint, no published API —
same ToS posture as the other -web scrape providers).
No live network access was available in this environment to smoke-test
against the real felo.ai endpoint, so validation is TDD via mocked fetch
(tests/unit/felo-web-executor.test.ts): thread-creation payload shape,
SSE parsing (answer-snapshot diffing + final_contexts drop), streaming
and non-streaming response translation, and error/timeout paths that
route through sanitizeErrorMessage() per the error-sanitization rule.
This commit is contained in:
committed by
GitHub
parent
a6d19cc4ba
commit
7b564ab5db
@@ -0,0 +1 @@
|
||||
- **feat(providers):** add Felo (felo.ai) as a free, no-signup, no-API-key chat/search-agent aggregator provider (`felo-web`) — joins the existing `-web` family (DuckDuckGo AI Chat, Blackbox, etc). Five models (`felo-chat`, `felo-search`, `felo-scholar`, `felo-social`, `felo-document`) map to Felo's search categories; the executor opens a search thread then translates Felo's bespoke SSE stream into OpenAI-compatible chunks (#6666).
|
||||
@@ -148,6 +148,11 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "duckduckgo-web", modelId: "llama-4-scout", displayName: "Llama 4 Scout", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
|
||||
{ provider: "duckduckgo-web", modelId: "mistral-small-2501", displayName: "Mistral Small", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
|
||||
{ provider: "duckduckgo-web", modelId: "o3-mini", displayName: "O3 Mini", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "duckduckgo-web", tos: "avoid" },
|
||||
{ provider: "felo-web", modelId: "felo-chat", displayName: "Felo Chat", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
|
||||
{ provider: "felo-web", modelId: "felo-search", displayName: "Felo Search", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
|
||||
{ provider: "felo-web", modelId: "felo-scholar", displayName: "Felo Scholar", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
|
||||
{ provider: "felo-web", modelId: "felo-social", displayName: "Felo Social", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
|
||||
{ provider: "felo-web", modelId: "felo-document", displayName: "Felo Document", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "felo-web", tos: "avoid" },
|
||||
{ provider: "fireworks", modelId: "deepseek-v4-flash", displayName: "DeepSeek V4 Flash", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" },
|
||||
{ provider: "fireworks", modelId: "deepseek-v4-pro", displayName: "DeepSeek V4 Pro", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" },
|
||||
{ provider: "fireworks", modelId: "glm-5p1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "fireworks", tos: "avoid" },
|
||||
|
||||
@@ -41,6 +41,7 @@ export const FREE_TIER_BUDGETS: Record<string, number> = {
|
||||
export const FREE_TIER_TOS: Record<string, TosVerdict> = {
|
||||
opencode: "avoid",
|
||||
"duckduckgo-web": "avoid",
|
||||
"felo-web": "avoid",
|
||||
agy: "avoid",
|
||||
kiro: "avoid",
|
||||
"amazon-q": "avoid",
|
||||
|
||||
@@ -121,6 +121,7 @@ import { longcatProvider } from "./registry/longcat/index.ts";
|
||||
import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts";
|
||||
import { vertexProvider } from "./registry/vertex/index.ts";
|
||||
import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts";
|
||||
import { felo_webProvider } from "./registry/felo-web/index.ts";
|
||||
import { xaiProvider } from "./registry/xai/index.ts";
|
||||
import { morphProvider } from "./registry/morph/index.ts";
|
||||
import { siliconflowProvider } from "./registry/siliconflow/index.ts";
|
||||
@@ -308,6 +309,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
"vertex-partner": vertex_partnerProvider,
|
||||
vertex: vertexProvider,
|
||||
"duckduckgo-web": duckduckgo_webProvider,
|
||||
"felo-web": felo_webProvider,
|
||||
xai: xaiProvider,
|
||||
morph: morphProvider,
|
||||
siliconflow: siliconflowProvider,
|
||||
|
||||
18
open-sse/config/providers/registry/felo-web/index.ts
Normal file
18
open-sse/config/providers/registry/felo-web/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const felo_webProvider: RegistryEntry = {
|
||||
id: "felo-web",
|
||||
alias: "felo",
|
||||
format: "openai",
|
||||
executor: "felo-web",
|
||||
baseUrl: "https://felo.ai/api-proxy/main/search/threads",
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [
|
||||
{ id: "felo-chat", name: "Felo Chat" },
|
||||
{ id: "felo-search", name: "Felo Search" },
|
||||
{ id: "felo-scholar", name: "Felo Scholar" },
|
||||
{ id: "felo-social", name: "Felo Social" },
|
||||
{ id: "felo-document", name: "Felo Document" },
|
||||
],
|
||||
};
|
||||
362
open-sse/executors/felo-web.ts
Normal file
362
open-sse/executors/felo-web.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
/**
|
||||
* FeloWebExecutor — anonymous, free access to Felo (felo.ai), a chat/search-agent
|
||||
* aggregator. No API key or session cookie required (`needs_auth = False` in the
|
||||
* g4f reference implementation, `g4f/Provider/Felo.py`, fetched 2026-07-17).
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /api-proxy/main/search/threads — opens a search thread, returns `stream_key`.
|
||||
* 2. GET /api/message/v1/stream/{stream_key}?offset=0 — SSE-shaped stream. Each line is
|
||||
* `data:{...}` (no space after the colon, unlike most SSE producers). The JSON payload
|
||||
* carries a double-encoded `content` string; parsing that yields `{ data: { type, data } }`
|
||||
* where `type` is `"answer"` (incremental/snapshot text) or `"final_contexts"` (sources,
|
||||
* dropped here — no OpenAI-compatible slot for citations on this translation path).
|
||||
*
|
||||
* Felo has no published API; this is a reverse-engineered, scrape-style integration in the
|
||||
* same family as `duckduckgo-web.ts` / `blackbox-web.ts` (see #6666 plan). It may break
|
||||
* without notice if Felo changes its frontend contract.
|
||||
*/
|
||||
|
||||
export const FELO_BASE = "https://felo.ai";
|
||||
export const FELO_THREADS_URL = `${FELO_BASE}/api-proxy/main/search/threads`;
|
||||
export const FELO_PROVIDER_PREFIX = "felo-web/";
|
||||
|
||||
export function feloStreamUrl(streamKey: string): string {
|
||||
return `${FELO_BASE}/api/message/v1/stream/${encodeURIComponent(streamKey)}?offset=0`;
|
||||
}
|
||||
|
||||
const FELO_USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36";
|
||||
|
||||
export const FELO_HEADERS: Record<string, string> = {
|
||||
Accept: "*/*",
|
||||
"Content-Type": "application/json",
|
||||
Origin: FELO_BASE,
|
||||
Referer: `${FELO_BASE}/search?q=hello`,
|
||||
"User-Agent": FELO_USER_AGENT,
|
||||
};
|
||||
|
||||
const FELO_STREAM_REQUEST_HEADERS: Record<string, string> = {
|
||||
Accept: "*/*",
|
||||
Origin: FELO_BASE,
|
||||
Referer: FELO_HEADERS.Referer,
|
||||
"User-Agent": FELO_USER_AGENT,
|
||||
};
|
||||
|
||||
// Mirrors g4f's `Felo.model_aliases` — Felo has no published model list; this
|
||||
// reverse-engineered mapping is the only reference (category drives which
|
||||
// search/answer pipeline Felo routes the query through).
|
||||
const FELO_MODEL_CATEGORIES: Record<string, string> = {
|
||||
"felo-chat": "chat",
|
||||
"felo-search": "google",
|
||||
"felo-scholar": "scholar",
|
||||
"felo-social": "social",
|
||||
"felo-document": "document",
|
||||
};
|
||||
|
||||
export const FELO_DEFAULT_MODEL = "felo-chat";
|
||||
|
||||
export function normalizeFeloModel(model: string | undefined | null): string {
|
||||
if (!model) return FELO_DEFAULT_MODEL;
|
||||
const clean = model.startsWith(FELO_PROVIDER_PREFIX)
|
||||
? model.slice(FELO_PROVIDER_PREFIX.length)
|
||||
: model;
|
||||
return Object.prototype.hasOwnProperty.call(FELO_MODEL_CATEGORIES, clean)
|
||||
? clean
|
||||
: FELO_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
export function resolveFeloCategory(model: string | undefined | null): string {
|
||||
return FELO_MODEL_CATEGORIES[normalizeFeloModel(model)];
|
||||
}
|
||||
|
||||
export function extractFeloLastUserPrompt(messages: Array<Record<string, unknown>>): string {
|
||||
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||
if (!lastUser) return "";
|
||||
const content = lastUser.content;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.map((part) => {
|
||||
if (part && typeof part === "object" && typeof (part as Record<string, unknown>).text === "string") {
|
||||
return (part as Record<string, unknown>).text as string;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildFeloThreadPayload(
|
||||
model: string | undefined | null,
|
||||
prompt: string
|
||||
): Record<string, unknown> {
|
||||
const searchUuid = randomUUID();
|
||||
return {
|
||||
query: prompt,
|
||||
search_uuid: searchUuid,
|
||||
lang: "",
|
||||
agent_lang: "en",
|
||||
search_options: { langcode: "en-US" },
|
||||
search_video: true,
|
||||
query_from: "default",
|
||||
category: resolveFeloCategory(model),
|
||||
model: "",
|
||||
auto_routing: true,
|
||||
mode: "concise",
|
||||
device_id: randomUUID().replaceAll("-", ""),
|
||||
source_message_rid: "",
|
||||
documents: [],
|
||||
document_action: "",
|
||||
slides_source: { type: "ask_question", files: {} },
|
||||
slide_template_uid: "",
|
||||
selected_resource_ids: [],
|
||||
process_id: searchUuid,
|
||||
stream_protocol: "message_center_v1",
|
||||
enable_task_state: true,
|
||||
};
|
||||
}
|
||||
|
||||
function extractFeloAnswerText(contentJson: unknown): string | null {
|
||||
if (!contentJson || typeof contentJson !== "object") return null;
|
||||
const data = (contentJson as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const dataRecord = data as Record<string, unknown>;
|
||||
if (dataRecord.type !== "answer") return null;
|
||||
const inner = dataRecord.data;
|
||||
if (!inner || typeof inner !== "object") return null;
|
||||
const text = (inner as Record<string, unknown>).text;
|
||||
return typeof text === "string" ? text : null;
|
||||
}
|
||||
|
||||
export interface FeloParsedLine {
|
||||
/** New text to emit for this line, or null when the line carried nothing new. */
|
||||
newText: string | null;
|
||||
/** Running "previous text" snapshot to pass into the next call. */
|
||||
nextPreviousText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single line of Felo's SSE-shaped stream, diffing against the running
|
||||
* snapshot the same way the g4f reference implementation does: each `answer`
|
||||
* event carries the full text-so-far, and only the new suffix is new content.
|
||||
*/
|
||||
export function parseFeloStreamLine(line: string, previousText: string): FeloParsedLine {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:{")) {
|
||||
return { newText: null, nextPreviousText: previousText };
|
||||
}
|
||||
|
||||
let outer: unknown;
|
||||
try {
|
||||
outer = JSON.parse(trimmed.slice(5));
|
||||
} catch {
|
||||
return { newText: null, nextPreviousText: previousText };
|
||||
}
|
||||
|
||||
const content = (outer as Record<string, unknown> | null)?.content;
|
||||
if (typeof content !== "string") {
|
||||
return { newText: null, nextPreviousText: previousText };
|
||||
}
|
||||
|
||||
let contentJson: unknown;
|
||||
try {
|
||||
contentJson = JSON.parse(content);
|
||||
} catch {
|
||||
return { newText: null, nextPreviousText: previousText };
|
||||
}
|
||||
|
||||
const text = extractFeloAnswerText(contentJson);
|
||||
if (text === null) {
|
||||
return { newText: null, nextPreviousText: previousText };
|
||||
}
|
||||
|
||||
if (text.startsWith(previousText)) {
|
||||
const newPart = text.slice(previousText.length);
|
||||
return newPart
|
||||
? { newText: newPart, nextPreviousText: text }
|
||||
: { newText: null, nextPreviousText: previousText };
|
||||
}
|
||||
|
||||
return { newText: text, nextPreviousText: text };
|
||||
}
|
||||
|
||||
/** Replay a full raw stream body through `parseFeloStreamLine`, returning the final text. */
|
||||
export function accumulateFeloStreamText(rawText: string): string {
|
||||
let previousText = "";
|
||||
for (const line of rawText.split("\n")) {
|
||||
previousText = parseFeloStreamLine(line, previousText).nextPreviousText;
|
||||
}
|
||||
return previousText;
|
||||
}
|
||||
|
||||
export class FeloWebExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("felo-web", { baseUrl: FELO_BASE });
|
||||
}
|
||||
|
||||
async testConnection(
|
||||
_credentials: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.getTimeoutMs());
|
||||
try {
|
||||
const mergedSignal = signal
|
||||
? AbortSignal.any([signal, controller.signal])
|
||||
: controller.signal;
|
||||
|
||||
const response = await fetch(FELO_THREADS_URL, {
|
||||
method: "POST",
|
||||
headers: FELO_HEADERS,
|
||||
body: JSON.stringify(buildFeloThreadPayload(FELO_DEFAULT_MODEL, "hi")),
|
||||
signal: mergedSignal,
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json().catch(() => null);
|
||||
return typeof (data as Record<string, unknown> | null)?.stream_key === "string";
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<Response> {
|
||||
const { model, body, stream, signal } = input;
|
||||
const bodyObj = (body || {}) as Record<string, unknown>;
|
||||
const messages = Array.isArray(bodyObj.messages)
|
||||
? (bodyObj.messages as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const isStreaming = stream !== false;
|
||||
|
||||
if (messages.length === 0) {
|
||||
return feloErrorResponse(400, "No messages provided");
|
||||
}
|
||||
const prompt = extractFeloLastUserPrompt(messages);
|
||||
if (!prompt) {
|
||||
return feloErrorResponse(400, "No user message content found");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.getTimeoutMs());
|
||||
const mergedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
const streamKey = await this.createFeloThread(model, prompt, mergedSignal);
|
||||
if (streamKey instanceof Response) {
|
||||
clearTimeout(timeout);
|
||||
return streamKey;
|
||||
}
|
||||
|
||||
const streamResponse = await fetch(feloStreamUrl(streamKey), {
|
||||
method: "GET",
|
||||
headers: FELO_STREAM_REQUEST_HEADERS,
|
||||
signal: mergedSignal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!streamResponse.ok || !streamResponse.body) {
|
||||
const status = !streamResponse.ok && streamResponse.status >= 500 ? 502 : streamResponse.status || 502;
|
||||
return feloErrorResponse(status, `Felo stream request failed with HTTP ${streamResponse.status}`);
|
||||
}
|
||||
|
||||
return await processFeloResponse(streamResponse, isStreaming);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return feloErrorResponse(499, "Request cancelled");
|
||||
}
|
||||
return feloErrorResponse(500, error instanceof Error ? error.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the resolved `stream_key`, or an error Response to propagate as-is. */
|
||||
private async createFeloThread(
|
||||
model: string | undefined,
|
||||
prompt: string,
|
||||
signal: AbortSignal
|
||||
): Promise<string | Response> {
|
||||
const threadResponse = await fetch(FELO_THREADS_URL, {
|
||||
method: "POST",
|
||||
headers: FELO_HEADERS,
|
||||
body: JSON.stringify(buildFeloThreadPayload(model, prompt)),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!threadResponse.ok) {
|
||||
const status = threadResponse.status >= 500 ? 502 : threadResponse.status;
|
||||
return feloErrorResponse(status, `Felo thread creation failed with HTTP ${threadResponse.status}`);
|
||||
}
|
||||
|
||||
const threadJson = await threadResponse.json().catch(() => null);
|
||||
const streamKey = (threadJson as Record<string, unknown> | null)?.stream_key;
|
||||
if (typeof streamKey !== "string" || !streamKey) {
|
||||
return feloErrorResponse(502, "Felo did not return a stream_key");
|
||||
}
|
||||
return streamKey;
|
||||
}
|
||||
}
|
||||
|
||||
function feloErrorResponse(status: number, message: string): Response {
|
||||
return new Response(JSON.stringify({ error: { message: sanitizeErrorMessage(message) } }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function buildFeloStreamTransform(): TransformStream<Uint8Array, Uint8Array> {
|
||||
let previousText = "";
|
||||
let buffer = "";
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const parsed = parseFeloStreamLine(line, previousText);
|
||||
previousText = parsed.nextPreviousText;
|
||||
if (!parsed.newText) continue;
|
||||
const openaiChunk = { choices: [{ delta: { content: parsed.newText }, index: 0 }] };
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(openaiChunk)}\n\n`));
|
||||
}
|
||||
},
|
||||
flush(controller) {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function processFeloResponse(response: Response, streaming: boolean): Promise<Response> {
|
||||
if (streaming) {
|
||||
if (!response.body) {
|
||||
return feloErrorResponse(500, "No response body");
|
||||
}
|
||||
const transformed = response.body.pipeThrough(buildFeloStreamTransform());
|
||||
return new Response(transformed, { headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
const rawText = await response.text();
|
||||
const fullText = accumulateFeloStreamText(rawText);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: fullText },
|
||||
index: 0,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
export const feloWebExecutor = new FeloWebExecutor();
|
||||
@@ -38,6 +38,7 @@ import { CopilotWebExecutor } from "./copilot-web.ts";
|
||||
import { CopilotM365WebExecutor } from "./copilot-m365-web.ts";
|
||||
import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
|
||||
import { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts";
|
||||
import { FeloWebExecutor } from "./felo-web.ts";
|
||||
import { T3ChatWebExecutor } from "./t3-chat-web.ts";
|
||||
import { ClaudeWebExecutor } from "./claude-web.ts";
|
||||
import { InnerAiExecutor } from "./inner-ai.ts";
|
||||
@@ -127,6 +128,8 @@ const executors = {
|
||||
"veo-free": new VeoAIFreeWebExecutor(), // Alias
|
||||
"duckduckgo-web": new DuckDuckGoWebExecutor(),
|
||||
ddgw: new DuckDuckGoWebExecutor(), // Alias
|
||||
"felo-web": new FeloWebExecutor(),
|
||||
felo: new FeloWebExecutor(), // Alias
|
||||
"t3-web": new T3ChatWebExecutor(),
|
||||
t3chat: new T3ChatWebExecutor(), // Alias
|
||||
"inner-ai": new InnerAiExecutor(),
|
||||
@@ -235,6 +238,7 @@ export { CopilotWebExecutor } from "./copilot-web.ts";
|
||||
export { CopilotM365WebExecutor } from "./copilot-m365-web.ts";
|
||||
export { VeoAIFreeWebExecutor } from "./veoaifree-web.ts";
|
||||
export { DuckDuckGoWebExecutor } from "./duckduckgo-web.ts";
|
||||
export { FeloWebExecutor } from "./felo-web.ts";
|
||||
export { ClaudeWebExecutor } from "./claude-web.ts";
|
||||
export { DeepSeekWebExecutor } from "./deepseek-web.ts";
|
||||
export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts";
|
||||
|
||||
@@ -35,6 +35,23 @@ export const NOAUTH_PROVIDERS = {
|
||||
freeNote: "Free — anonymous access to multiple AI models via DuckDuckGo.",
|
||||
authHint: "No credentials required — DuckDuckGo AI Chat is anonymous and free.",
|
||||
},
|
||||
"felo-web": {
|
||||
id: "felo-web",
|
||||
alias: "felo",
|
||||
name: "Felo",
|
||||
icon: "travel_explore",
|
||||
color: "#5B7FFF",
|
||||
textIcon: "FL",
|
||||
website: "https://felo.ai",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["llm"],
|
||||
freeNote: "Free — anonymous access to Felo's chat/search-agent aggregator. No API key.",
|
||||
authHint: "No credentials required — Felo is a free, no-signup chat/search aggregator.",
|
||||
notice: {
|
||||
text: "Felo uses a reverse-engineered public endpoint (no official API). No signup or API key needed. Behavior may change without notice if Felo updates its frontend.",
|
||||
},
|
||||
},
|
||||
theoldllm: {
|
||||
id: "theoldllm",
|
||||
alias: "tllm",
|
||||
|
||||
@@ -1508,6 +1508,29 @@
|
||||
"stream": "https://api.featherless.ai/v1/chat/completions"
|
||||
}
|
||||
},
|
||||
"felo-web": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
"apiKey": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"nonStream": {
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"oauth": {
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer <TOK>",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"nonStream": "https://felo.ai/api-proxy/main/search/threads",
|
||||
"stream": "https://felo.ai/api-proxy/main/search/threads"
|
||||
}
|
||||
},
|
||||
"fireworks": {
|
||||
"format": "openai",
|
||||
"headers": {
|
||||
|
||||
325
tests/unit/felo-web-executor.test.ts
Normal file
325
tests/unit/felo-web-executor.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ExecuteInput } from "../../open-sse/executors/base.ts";
|
||||
|
||||
const mod = await import("../../open-sse/executors/felo-web.ts");
|
||||
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
|
||||
const {
|
||||
FeloWebExecutor,
|
||||
FELO_THREADS_URL,
|
||||
feloStreamUrl,
|
||||
normalizeFeloModel,
|
||||
resolveFeloCategory,
|
||||
extractFeloLastUserPrompt,
|
||||
buildFeloThreadPayload,
|
||||
parseFeloStreamLine,
|
||||
accumulateFeloStreamText,
|
||||
} = mod;
|
||||
|
||||
type FetchCall = { url: string; init: RequestInit };
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
let calls: FetchCall[] = [];
|
||||
|
||||
function threadsResponse(streamKey = "sk-123", status = 200): Response {
|
||||
return new Response(JSON.stringify({ stream_key: streamKey }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a Felo-shaped `data:{...}` stream body from a list of answer snapshots. */
|
||||
function feloStreamResponse(answerSnapshots: string[], includeSourcesEvent = false): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const lines: string[] = [];
|
||||
for (const text of answerSnapshots) {
|
||||
const contentJson = { data: { type: "answer", data: { text } } };
|
||||
lines.push(`data:${JSON.stringify({ content: JSON.stringify(contentJson) })}`);
|
||||
}
|
||||
if (includeSourcesEvent) {
|
||||
const contentJson = {
|
||||
data: {
|
||||
type: "final_contexts",
|
||||
data: { sources: [{ link: "https://example.com", title: "Example" }] },
|
||||
},
|
||||
};
|
||||
lines.push(`data:${JSON.stringify({ content: JSON.stringify(contentJson) })}`);
|
||||
}
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(lines.join("\n") + "\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
function mockFetch(handler: (url: string, init: RequestInit) => Response | Promise<Response>): void {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, init });
|
||||
return handler(url, init);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
function jsonBody(init: RequestInit): Record<string, unknown> {
|
||||
return JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function baseExecuteInput(overrides: Partial<ExecuteInput> = {}): ExecuteInput {
|
||||
return {
|
||||
model: "felo-chat",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {},
|
||||
signal: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
describe("FeloWebExecutor — registry wiring", () => {
|
||||
it("is registered as a canonical noAuth provider (providers.ts)", () => {
|
||||
const provider = AI_PROVIDERS["felo-web"];
|
||||
assert.ok(provider, "felo-web should be a canonical provider");
|
||||
assert.equal(provider.noAuth, true);
|
||||
});
|
||||
|
||||
it("is registered in the provider REGISTRY with the felo-web executor", () => {
|
||||
const entry = REGISTRY["felo-web"];
|
||||
assert.ok(entry, "felo-web should have a REGISTRY entry");
|
||||
assert.equal(entry.executor, "felo-web");
|
||||
assert.equal(entry.authType, "none");
|
||||
assert.ok(entry.models.some((m) => m.id === "felo-chat"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeloWebExecutor — pure helpers", () => {
|
||||
it("normalizeFeloModel: strips the felo-web/ prefix and falls back to felo-chat", () => {
|
||||
assert.equal(normalizeFeloModel("felo-web/felo-search"), "felo-search");
|
||||
assert.equal(normalizeFeloModel("felo-scholar"), "felo-scholar");
|
||||
assert.equal(normalizeFeloModel("not-a-real-model"), "felo-chat");
|
||||
assert.equal(normalizeFeloModel(undefined), "felo-chat");
|
||||
});
|
||||
|
||||
it("resolveFeloCategory: maps each model alias to its g4f category", () => {
|
||||
assert.equal(resolveFeloCategory("felo-chat"), "chat");
|
||||
assert.equal(resolveFeloCategory("felo-search"), "google");
|
||||
assert.equal(resolveFeloCategory("felo-scholar"), "scholar");
|
||||
assert.equal(resolveFeloCategory("felo-social"), "social");
|
||||
assert.equal(resolveFeloCategory("felo-document"), "document");
|
||||
});
|
||||
|
||||
it("extractFeloLastUserPrompt: picks the last user message, string content", () => {
|
||||
const prompt = extractFeloLastUserPrompt([
|
||||
{ role: "system", content: "be nice" },
|
||||
{ role: "user", content: "first" },
|
||||
{ role: "assistant", content: "reply" },
|
||||
{ role: "user", content: "second" },
|
||||
]);
|
||||
assert.equal(prompt, "second");
|
||||
});
|
||||
|
||||
it("extractFeloLastUserPrompt: joins array-of-parts content", () => {
|
||||
const prompt = extractFeloLastUserPrompt([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "part one" },
|
||||
{ type: "text", text: "part two" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(prompt, "part one\npart two");
|
||||
});
|
||||
|
||||
it("buildFeloThreadPayload: carries the query and resolved category", () => {
|
||||
const payload = buildFeloThreadPayload("felo-search", "hello world");
|
||||
assert.equal(payload.query, "hello world");
|
||||
assert.equal(payload.category, "google");
|
||||
assert.equal(payload.stream_protocol, "message_center_v1");
|
||||
assert.equal(typeof payload.search_uuid, "string");
|
||||
assert.ok((payload.search_uuid as string).length > 0);
|
||||
});
|
||||
|
||||
it("parseFeloStreamLine: ignores non-data lines and malformed JSON", () => {
|
||||
assert.deepEqual(parseFeloStreamLine("", "prev"), { newText: null, nextPreviousText: "prev" });
|
||||
assert.deepEqual(parseFeloStreamLine("not-a-data-line", "prev"), {
|
||||
newText: null,
|
||||
nextPreviousText: "prev",
|
||||
});
|
||||
assert.deepEqual(parseFeloStreamLine("data:{not json", "prev"), {
|
||||
newText: null,
|
||||
nextPreviousText: "prev",
|
||||
});
|
||||
});
|
||||
|
||||
it("parseFeloStreamLine: diffs incremental answer snapshots against the running text", () => {
|
||||
const line1 = `data:${JSON.stringify({
|
||||
content: JSON.stringify({ data: { type: "answer", data: { text: "Hel" } } }),
|
||||
})}`;
|
||||
const line2 = `data:${JSON.stringify({
|
||||
content: JSON.stringify({ data: { type: "answer", data: { text: "Hello" } } }),
|
||||
})}`;
|
||||
|
||||
const first = parseFeloStreamLine(line1, "");
|
||||
assert.equal(first.newText, "Hel");
|
||||
assert.equal(first.nextPreviousText, "Hel");
|
||||
|
||||
const second = parseFeloStreamLine(line2, first.nextPreviousText);
|
||||
assert.equal(second.newText, "lo");
|
||||
assert.equal(second.nextPreviousText, "Hello");
|
||||
});
|
||||
|
||||
it("parseFeloStreamLine: ignores final_contexts events (no OpenAI-compatible slot)", () => {
|
||||
const line = `data:${JSON.stringify({
|
||||
content: JSON.stringify({
|
||||
data: { type: "final_contexts", data: { sources: [{ link: "https://x", title: "X" }] } },
|
||||
}),
|
||||
})}`;
|
||||
assert.deepEqual(parseFeloStreamLine(line, "prev"), { newText: null, nextPreviousText: "prev" });
|
||||
});
|
||||
|
||||
it("accumulateFeloStreamText: replays a full stream body into the final text", () => {
|
||||
const raw = [
|
||||
`data:${JSON.stringify({ content: JSON.stringify({ data: { type: "answer", data: { text: "Hi" } } }) })}`,
|
||||
`data:${JSON.stringify({
|
||||
content: JSON.stringify({ data: { type: "answer", data: { text: "Hi there" } } }),
|
||||
})}`,
|
||||
].join("\n");
|
||||
assert.equal(accumulateFeloStreamText(raw), "Hi there");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeloWebExecutor — execute() input validation", () => {
|
||||
it("rejects an empty messages array with 400", async () => {
|
||||
const executor = new FeloWebExecutor();
|
||||
const response = await executor.execute(baseExecuteInput({ body: { messages: [] } }));
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const responseBody = (await response.json()) as { error?: { message?: string } };
|
||||
assert.ok(responseBody.error?.message);
|
||||
});
|
||||
|
||||
it("rejects messages with no extractable user prompt with 400", async () => {
|
||||
const executor = new FeloWebExecutor();
|
||||
const response = await executor.execute(
|
||||
baseExecuteInput({ body: { messages: [{ role: "system", content: "no user turn" }] } })
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeloWebExecutor — execute() happy path (mocked fetch)", () => {
|
||||
it("POSTs the thread payload, GETs the stream, and returns non-streaming OpenAI JSON", async () => {
|
||||
mockFetch((url) => {
|
||||
if (url === FELO_THREADS_URL) return threadsResponse("sk-abc");
|
||||
if (url === feloStreamUrl("sk-abc")) return feloStreamResponse(["Hel", "Hello", "Hello there"], true);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const executor = new FeloWebExecutor();
|
||||
const response = await executor.execute(baseExecuteInput());
|
||||
|
||||
assert.equal(calls.length, 2, "should call threads then stream exactly once each");
|
||||
assert.equal(calls[0].init.method, "POST");
|
||||
const threadPayload = jsonBody(calls[0].init);
|
||||
assert.equal(threadPayload.query, "hi");
|
||||
assert.equal(threadPayload.category, "chat");
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const json = (await response.json()) as {
|
||||
choices: Array<{ message: { role: string; content: string }; finish_reason: string }>;
|
||||
};
|
||||
assert.equal(json.choices[0].message.content, "Hello there");
|
||||
assert.equal(json.choices[0].message.role, "assistant");
|
||||
assert.equal(json.choices[0].finish_reason, "stop");
|
||||
});
|
||||
|
||||
it("streams OpenAI-compatible SSE chunks ending with [DONE]", async () => {
|
||||
mockFetch((url) => {
|
||||
if (url === FELO_THREADS_URL) return threadsResponse("sk-stream");
|
||||
if (url === feloStreamUrl("sk-stream")) return feloStreamResponse(["A", "AB", "ABC"]);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const executor = new FeloWebExecutor();
|
||||
const response = await executor.execute(baseExecuteInput({ stream: true }));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(response.body);
|
||||
const text = await response.text();
|
||||
assert.match(text, /"content":"A"/);
|
||||
assert.match(text, /"content":"B"/);
|
||||
assert.match(text, /"content":"C"/);
|
||||
assert.match(text, /data: \[DONE\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeloWebExecutor — error paths", () => {
|
||||
it("propagates a 5xx from thread creation as a sanitized 502", async () => {
|
||||
mockFetch((url) => {
|
||||
if (url === FELO_THREADS_URL) return new Response("upstream on fire", { status: 503 });
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const executor = new FeloWebExecutor();
|
||||
const response = await executor.execute(baseExecuteInput());
|
||||
|
||||
assert.equal(response.status, 502);
|
||||
const responseBody = (await response.json()) as { error: { message: string } };
|
||||
assert.ok(responseBody.error.message.includes("HTTP 503"));
|
||||
assert.ok(!responseBody.error.message.includes("at /"), "must not leak a stack trace");
|
||||
});
|
||||
|
||||
it("returns 502 when the threads response omits stream_key", async () => {
|
||||
mockFetch((url) => {
|
||||
if (url === FELO_THREADS_URL) {
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
const executor = new FeloWebExecutor();
|
||||
const response = await executor.execute(baseExecuteInput());
|
||||
|
||||
assert.equal(response.status, 502);
|
||||
const responseBody = (await response.json()) as { error: { message: string } };
|
||||
assert.match(responseBody.error.message, /stream_key/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeloWebExecutor — testConnection", () => {
|
||||
it("returns true when threads endpoint responds with a stream_key", async () => {
|
||||
mockFetch(() => threadsResponse("sk-health"));
|
||||
const executor = new FeloWebExecutor();
|
||||
assert.equal(await executor.testConnection({}), true);
|
||||
});
|
||||
|
||||
it("returns false on a non-ok response", async () => {
|
||||
mockFetch(() => new Response("nope", { status: 500 }));
|
||||
const executor = new FeloWebExecutor();
|
||||
assert.equal(await executor.testConnection({}), false);
|
||||
});
|
||||
|
||||
it("returns false on a network error", async () => {
|
||||
globalThis.fetch = (async () => {
|
||||
throw new Error("network down");
|
||||
}) as typeof fetch;
|
||||
const executor = new FeloWebExecutor();
|
||||
assert.equal(await executor.testConnection({}), false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user