feat(providers): add Felo chat-aggregator provider (#6666) (#7599)

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:
Diego Rodrigues de Sa e Souza
2026-07-17 11:31:54 -03:00
committed by GitHub
parent a6d19cc4ba
commit 7b564ab5db
10 changed files with 758 additions and 0 deletions

View File

@@ -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).

View File

@@ -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" },

View File

@@ -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",

View File

@@ -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,

View 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" },
],
};

View 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();

View File

@@ -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";

View File

@@ -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",

View File

@@ -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": {

View 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);
});
});