diff --git a/changelog.d/features/6666-felo-chat-aggregator-provider.md b/changelog.d/features/6666-felo-chat-aggregator-provider.md new file mode 100644 index 0000000000..bea5f71f5a --- /dev/null +++ b/changelog.d/features/6666-felo-chat-aggregator-provider.md @@ -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). diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index a9accafb60..6b807affb1 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -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" }, diff --git a/open-sse/config/freeTierCatalog.ts b/open-sse/config/freeTierCatalog.ts index 5262697625..c6e17698e1 100644 --- a/open-sse/config/freeTierCatalog.ts +++ b/open-sse/config/freeTierCatalog.ts @@ -41,6 +41,7 @@ export const FREE_TIER_BUDGETS: Record = { export const FREE_TIER_TOS: Record = { opencode: "avoid", "duckduckgo-web": "avoid", + "felo-web": "avoid", agy: "avoid", kiro: "avoid", "amazon-q": "avoid", diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 8763f6326f..848ce8bfb7 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -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 = { "vertex-partner": vertex_partnerProvider, vertex: vertexProvider, "duckduckgo-web": duckduckgo_webProvider, + "felo-web": felo_webProvider, xai: xaiProvider, morph: morphProvider, siliconflow: siliconflowProvider, diff --git a/open-sse/config/providers/registry/felo-web/index.ts b/open-sse/config/providers/registry/felo-web/index.ts new file mode 100644 index 0000000000..4cf8fcae25 --- /dev/null +++ b/open-sse/config/providers/registry/felo-web/index.ts @@ -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" }, + ], +}; diff --git a/open-sse/executors/felo-web.ts b/open-sse/executors/felo-web.ts new file mode 100644 index 0000000000..847b8d3f77 --- /dev/null +++ b/open-sse/executors/felo-web.ts @@ -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 = { + 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 = { + 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 = { + "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>): 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).text === "string") { + return (part as Record).text as string; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +export function buildFeloThreadPayload( + model: string | undefined | null, + prompt: string +): Record { + 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).data; + if (!data || typeof data !== "object") return null; + const dataRecord = data as Record; + if (dataRecord.type !== "answer") return null; + const inner = dataRecord.data; + if (!inner || typeof inner !== "object") return null; + const text = (inner as Record).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 | 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, + signal?: AbortSignal + ): Promise { + 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 | null)?.stream_key === "string"; + } catch { + return false; + } finally { + clearTimeout(timeout); + } + } + + async execute(input: ExecuteInput): Promise { + const { model, body, stream, signal } = input; + const bodyObj = (body || {}) as Record; + const messages = Array.isArray(bodyObj.messages) + ? (bodyObj.messages as Array>) + : []; + 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 { + 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 | 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 { + let previousText = ""; + let buffer = ""; + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + + return new TransformStream({ + 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 { + 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(); diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 68168fd8cf..ca832b8a6d 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -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"; diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index a33e0d08e4..864ee048f1 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.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", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index d8fc6518c9..3cf5ae91fc 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1508,6 +1508,29 @@ "stream": "https://api.featherless.ai/v1/chat/completions" } }, + "felo-web": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "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": { diff --git a/tests/unit/felo-web-executor.test.ts b/tests/unit/felo-web-executor.test.ts new file mode 100644 index 0000000000..232ca72b55 --- /dev/null +++ b/tests/unit/felo-web-executor.test.ts @@ -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): 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 { + return JSON.parse(String(init.body)) as Record; +} + +function baseExecuteInput(overrides: Partial = {}): 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); + }); +});