From cbf23772ec2d9842420ff454f599b1a5a2884602 Mon Sep 17 00:00:00 2001 From: "Adrian A. Firmansyah" <34051784+adrianaryaputra@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:53:24 +0700 Subject: [PATCH] feat(providers): implement native support for Freebuff AI gateway (#6793) (#10531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds native support for Freebuff (Codebuff CLI free-tier gateway): executor with upstream session acquisition, agent-run lifecycle (START/FINISH), canonical system-prompt injection, and model→agent mapping for 9 free models; registry entry, dashboard branding/icons, and API-key validation. Closes #6793. Validated in an isolated worktree boarded onto origin/release/v3.8.50 (0 conflicts after resolving generated provider-count drift, 15 files): - 9/9 focused tests pass (freebuff-provider, providers-constants-split). - Dropped one out-of-scope, unrelated hunk in scripts/build/build-next-isolated.mjs (build-memory heap tuning) that had nothing to do with the Freebuff provider — kept the branch scoped to its stated purpose. - check-changelog-integrity: OK. - file-size: gateways.ts and AddApiKeyModal.tsx crossed the frozen cap by +15/+5 lines (irreducible catalog-entry + credential-hint additions) — rebaselined with justification, pushed fix-in-place to the PR branch. - typecheck:core: clean. - check-complexity / check-cognitive-complexity: OK, both under baseline. Co-authored-by: adrianaryaputra --- config/quality/file-size-baseline.json | 5 +- open-sse/config/providers/index.ts | 2 + .../providers/registry/freebuff/index.ts | 70 +++++++ open-sse/executors/freebuff.ts | 172 ++++++++++++++++++ open-sse/executors/index.ts | 3 + public/providers/freebuff-dark.svg | 7 + public/providers/freebuff-light.svg | 7 + public/providers/freebuff.png | Bin 0 -> 5969 bytes public/providers/freebuff.svg | 7 + .../[id]/components/modals/AddApiKeyModal.tsx | 15 +- src/lib/providers/validation.ts | 32 ++++ src/shared/components/ProviderIcon.tsx | 1 + .../constants/providers/apikey/gateways.ts | 15 ++ tests/snapshots/provider/translate-path.json | 23 +++ tests/unit/freebuff-provider.test.ts | 59 ++++++ tests/unit/providers-constants-split.test.ts | 13 +- 16 files changed, 418 insertions(+), 13 deletions(-) create mode 100644 open-sse/config/providers/registry/freebuff/index.ts create mode 100644 open-sse/executors/freebuff.ts create mode 100644 public/providers/freebuff-dark.svg create mode 100644 public/providers/freebuff-light.svg create mode 100644 public/providers/freebuff.png create mode 100644 public/providers/freebuff.svg create mode 100644 tests/unit/freebuff-provider.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index f19fc0ea0b..72bc030875 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_08_20_10531_freebuff_provider": "PR #10531 (adrianaryaputra, feat/freebuff-provider-support, closes #6793) own growth: src/shared/constants/providers/apikey/gateways.ts 1283->1298 (+15, the freebuff APIKEY_PROVIDERS_GATEWAYS catalog entry, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines) and src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx 1062->1067 (+5, freebuff credential placeholder/hint at the existing per-provider switch chokepoint). Covered by tests/unit/freebuff-provider.test.ts (9/9 passing).", "_rebaseline_2026_08_20_10574_reasoning_transport_fallback": "PR #10574 (jackjinke, fix/responses-reasoning-transport, fixes #10550) own growth: src/sse/handlers/chatHelpers.ts 1017->1019 (+2 = the new reasoningTransportFallback option threaded through executeChatWithBreaker's options destructure and its downstream handleSingleModel call, at the existing per-attempt options-passthrough chokepoint; not extractable without splitting the option-forwarding call itself). Covered by the PR's own reasoning-policy test suite (tests/unit/chatcore-translation-paths.test.ts, tests/unit/combo-attempt-body-isolation-7847.test.ts, tests/unit/reasoning-cache.test.ts, tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts among others), 446/446 focused tests passing.", "_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).", "_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).", @@ -442,10 +443,10 @@ "src/shared/components/ModelSelectModal.tsx": 1138, "src/shared/constants/providers/apikey/gateways.ts": 1250 }, - "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1062, + "src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1067, "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/shared/components/ModelSelectModal.tsx": 1138, - "src/shared/constants/providers/apikey/gateways.ts": 1283, + "src/shared/constants/providers/apikey/gateways.ts": 1298, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index a7067a022e..00861b2422 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -18,6 +18,7 @@ import { deepaiProvider } from "./registry/deepai/index.ts"; import { upstageProvider } from "./registry/upstage/index.ts"; import { nebiusProvider } from "./registry/nebius/index.ts"; import { fireworksProvider } from "./registry/fireworks/index.ts"; +import { freebuffProvider } from "./registry/freebuff/index.ts"; import { llamagateProvider } from "./registry/llamagate/index.ts"; import { glmProvider } from "./registry/glm/index.ts"; import { glmtProvider } from "./registry/glm/t/index.ts"; @@ -282,6 +283,7 @@ export const REGISTRY: Record = { deepai: deepaiProvider, nebius: nebiusProvider, fireworks: fireworksProvider, + freebuff: freebuffProvider, llamagate: llamagateProvider, glm: glmProvider, glmt: glmtProvider, diff --git a/open-sse/config/providers/registry/freebuff/index.ts b/open-sse/config/providers/registry/freebuff/index.ts new file mode 100644 index 0000000000..713f469548 --- /dev/null +++ b/open-sse/config/providers/registry/freebuff/index.ts @@ -0,0 +1,70 @@ +import type { RegistryEntry } from "../../shared.ts"; + +export const freebuffProvider: RegistryEntry = { + id: "freebuff", + alias: "fb", + format: "openai", + executor: "freebuff", + baseUrl: "https://www.codebuff.com/api/v1", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "openai/gpt-5.6-luna", + name: "GPT-5.6 Luna", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "minimax/minimax-m3", + name: "MiniMax M3", + supportsVision: true, + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "mimo/mimo-v2.5", + name: "MiMo v2.5", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "z-ai/glm-5.2", + name: "GLM 5.2", + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "crof/kimi-k3-eco", + name: "Kimi K3 Eco", + supportsVision: true, + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "anthropic/claude-fable-5", + name: "Claude Fable 5", + supportsVision: true, + supportsReasoning: true, + contextLength: 131_072, + }, + { + id: "meta/muse-spark-1.2-contributor", + name: "Meta Muse Spark 1.2 Contributor", + supportsReasoning: true, + contextLength: 131_072, + }, + ], +}; diff --git a/open-sse/executors/freebuff.ts b/open-sse/executors/freebuff.ts new file mode 100644 index 0000000000..bc2de30f63 --- /dev/null +++ b/open-sse/executors/freebuff.ts @@ -0,0 +1,172 @@ +import { + BaseExecutor, + type ExecuteInput, +} from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; + +const MODEL_TO_AGENT: Record = { + "deepseek/deepseek-v4-flash": "base2-free-deepseek-flash", + "deepseek/deepseek-v4-pro": "base2-free-deepseek", + "openai/gpt-5.6-luna": "base2-free-luna", + "minimax/minimax-m3": "base2-free-minimax-m3", + "mimo/mimo-v2.5": "base2-free-mimo", + "z-ai/glm-5.2": "base2-free-glm", + "crof/kimi-k3-eco": "base2-free-kimi-k3-eco", + "anthropic/claude-fable-5": "base2-free-fable", + "meta/muse-spark-1.2-contributor": "base2-free-muse-spark", +}; + +function generateClientSessionId(): string { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + let out = ""; + for (let i = 0; i < 13; i++) { + out += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return out; +} + +export class FreebuffExecutor extends BaseExecutor { + constructor() { + super("freebuff", (PROVIDERS as Record).freebuff as string || "freebuff"); + } + + override async execute(input: ExecuteInput) { + const { model, body, stream, credentials, signal } = input; + const token = credentials?.apiKey || credentials?.accessToken || ""; + + if (!token) { + return { + response: new Response( + JSON.stringify({ error: { message: "Freebuff Auth Token required", type: "authentication_error" } }), + { status: 401, headers: { "Content-Type": "application/json" } } + ), + }; + } + + const requestedModel = typeof model === "string" ? model.replace(/^freebuff\//, "") : (model || "deepseek/deepseek-v4-flash"); + const agentId = MODEL_TO_AGENT[requestedModel] || "base2-free"; + + const authHeaders = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "codebuff/0.1.0 (darwin-arm64)", + }; + + let instanceId = ""; + let runId = ""; + + // 1. Session acquisition + try { + const sessionRes = await fetch("https://www.codebuff.com/api/v1/freebuff/session", { + method: "POST", + headers: { + ...authHeaders, + "x-freebuff-model": requestedModel, + }, + body: JSON.stringify({}), + signal, + }); + if (sessionRes.ok) { + const data = (await sessionRes.json()) as { instanceId?: string }; + instanceId = data.instanceId || ""; + } else { + const errText = await sessionRes.text(); + return { + response: new Response( + JSON.stringify({ error: { message: `Freebuff session failed (${sessionRes.status}): ${errText}`, type: "upstream_error" } }), + { status: sessionRes.status, headers: { "Content-Type": "application/json" } } + ), + }; + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return { + response: new Response( + JSON.stringify({ error: { message: `Freebuff session network error: ${msg}`, type: "upstream_error" } }), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + }; + } + + // 2. Start agent run + try { + const runRes = await fetch("https://www.codebuff.com/api/v1/agent-runs", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ action: "START", agentId }), + signal, + }); + if (runRes.ok) { + const runData = (await runRes.json()) as { runId?: string }; + runId = runData.runId || ""; + } + } catch {} + + // 3. Prepare Chat Payload & Buffy System Prompt + const incomingMessages = Array.isArray(body?.messages) ? [...body.messages] : []; + const hasBuffyPrompt = + incomingMessages.length > 0 && + incomingMessages[0].role === "system" && + typeof incomingMessages[0].content === "string" && + incomingMessages[0].content.trim().startsWith("You are Buffy"); + + if (!hasBuffyPrompt) { + incomingMessages.unshift({ + role: "system", + content: "You are Buffy, the strategic coding assistant.", + }); + } + + const clientSessionId = generateClientSessionId(); + const upstreamBody = { + ...(body || {}), + model: requestedModel, + messages: incomingMessages, + stream: stream !== false, + codebuff_metadata: { + run_id: runId, + cost_mode: "free", + client_id: clientSessionId, + freebuff_instance_id: instanceId, + ...((body as Record)?.codebuff_metadata as Record || {}), + }, + }; + + const completionHeaders = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "ai-sdk/openai-compatible/1.0.25/codebuff", + Accept: "application/json, text/event-stream", + "x-freebuff-instance-id": instanceId, + ...(runId ? { "x-codebuff-run-id": runId } : {}), + "x-codebuff-agent-id": agentId, + }; + + // 4. Chat Completion + const completionUrl = "https://www.codebuff.com/api/v1/chat/completions"; + const response = await fetch(completionUrl, { + method: "POST", + headers: completionHeaders, + body: JSON.stringify(upstreamBody), + signal, + }); + + // 5. Finish agent run (background) + if (runId) { + void fetch("https://www.codebuff.com/api/v1/agent-runs", { + method: "POST", + headers: authHeaders, + body: JSON.stringify({ + action: "FINISH", + runId, + status: "completed", + totalSteps: 1, + directCredits: 0, + totalCredits: 0, + }), + }).catch(() => {}); + } + + return { response }; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 8678fb6105..34f3bafe73 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -14,6 +14,7 @@ import { BedrockExecutor } from "./bedrock.ts"; import { GlmExecutor } from "./glm.ts"; import { PollinationsExecutor } from "./pollinations.ts"; import { CloudflareAIExecutor } from "./cloudflare-ai.ts"; +import { FreebuffExecutor } from "./freebuff.ts"; import { OpencodeExecutor } from "./opencode.ts"; import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; @@ -117,6 +118,8 @@ const executors = { pol: new PollinationsExecutor(), // Alias "cloudflare-ai": new CloudflareAIExecutor(), cf: new CloudflareAIExecutor(), // Alias + freebuff: new FreebuffExecutor(), + fb: new FreebuffExecutor(), // Alias "opencode-zen": new OpencodeExecutor("opencode-zen"), "opencode-go": new OpencodeExecutor("opencode-go"), opencode: new OpencodeExecutor("opencode-zen"), // Alias for opencode-zen diff --git a/public/providers/freebuff-dark.svg b/public/providers/freebuff-dark.svg new file mode 100644 index 0000000000..0a6d1156fb --- /dev/null +++ b/public/providers/freebuff-dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/providers/freebuff-light.svg b/public/providers/freebuff-light.svg new file mode 100644 index 0000000000..0a6d1156fb --- /dev/null +++ b/public/providers/freebuff-light.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/public/providers/freebuff.png b/public/providers/freebuff.png new file mode 100644 index 0000000000000000000000000000000000000000..54806e0831485ec96658a790d63c2b4f312f2c80 GIT binary patch literal 5969 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 a2N>cu8E-4sfA(Bn>2|n!x|BIH&i)rPBP$L7 literal 0 HcmV?d00001 diff --git a/public/providers/freebuff.svg b/public/providers/freebuff.svg new file mode 100644 index 0000000000..0a6d1156fb --- /dev/null +++ b/public/providers/freebuff.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index de377cd9cb..8bdddc2aa7 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -88,6 +88,7 @@ export default function AddApiKeyModal({ const isModal = provider === "modal"; const isGlm = isGlmProvider(provider); const isQoder = provider === "qoder"; + const isFreebuff = provider === "freebuff"; const openRouterPreset = useOpenRouterPresetControl(provider, t); const isCloudflare = provider === "cloudflare-ai"; const localProviderMetadata = getLocalProviderMetadata(provider); @@ -191,9 +192,11 @@ export default function AddApiKeyModal({ ? webSessionCredential.placeholder : isQoder ? t("qoderPatPlaceholder") - : apiKeyOptional - ? t("optional") - : undefined; + : isFreebuff + ? "Enter Freebuff / Codebuff Auth Token (e.g. 038fcdf9-...)" + : apiKeyOptional + ? t("optional") + : undefined; const apiCredentialHint = isModal ? providerText( t, @@ -202,8 +205,10 @@ export default function AddApiKeyModal({ ) : isQoder ? t("qoderPatHint") - : isWebSessionCredential - ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) + : isFreebuff + ? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester." + : isWebSessionCredential + ? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false) : isLocalSelfHostedProvider ? t("localProviderApiKeyOptionalHint", { provider: localProviderMetadata?.name || providerName || provider || "", diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index bb970b7af5..7f8180dee0 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -140,6 +140,37 @@ export { validateWebCookieProvider, bytezValidationResultFromStatus }; // validateKiroApiKeyRuntimeProbe now live in ./validation/webCookie and ./validation/kiro. // They are re-exported above to preserve the historical public surface. +export async function validateFreebuffProvider({ apiKey }: { apiKey: string }) { + if (!apiKey) { + return { valid: false, error: "Freebuff Auth Token required", unsupported: false }; + } + try { + const res = await fetch("https://www.codebuff.com/api/v1/freebuff/session", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + "User-Agent": "codebuff/0.1.0 (darwin-arm64)", + "x-freebuff-model": "deepseek/deepseek-v4-flash", + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(15000), + }); + + if (res.ok || res.status === 409) { + return { valid: true, error: null }; + } + if (res.status === 401 || res.status === 403) { + return { valid: false, error: "Invalid or expired Freebuff Auth Token", unsupported: false }; + } + const errText = await res.text().catch(() => ""); + return { valid: false, error: `Freebuff validation returned ${res.status}: ${errText.slice(0, 100)}`, unsupported: false }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + return { valid: false, error: `Freebuff validation network error: ${msg}`, unsupported: false }; + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { provider = typeof provider === "string" ? resolveProviderId(provider) : provider; const requiresApiKey = !providerAllowsOptionalApiKey(provider); @@ -196,6 +227,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi firefly: validateAdobeFireflyProvider, qoder: validateQoderProvider, kiro: validateKiroProvider, + freebuff: validateFreebuffProvider, "command-code": validateCommandCodeProvider, huggingface: validateHuggingFaceProvider, // #5422: auth-only probe — Bytez 404s on every chat model until the account adds it to diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 9e3524561a..aeb6f777ba 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -115,6 +115,7 @@ const KNOWN_SVGS = new Set([ "fal", "fireworks", "freeaiapikey", + "freebuff", "freemodel-dev", "friendli", "galadriel", diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 1f2bd59979..606d6eb493 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -19,6 +19,21 @@ export const APIKEY_PROVIDERS_GATEWAYS = { "Create an API key at https://cheaperinference.com/?utm_source=omniroute (needs the `inference` scope), then paste the ir_live_… token here.", passthroughModels: true, }, + freebuff: { + id: "freebuff", + alias: "freebuff", + name: "Freebuff", + icon: "terminal", + color: "#10B981", + textIcon: "FB", + website: "https://freebuff.com", + hasFree: true, + serviceKinds: ["llm"], + authHint: "Enter Freebuff / Codebuff Auth Token (obtained via CLI login or automated harvester).", + freeNote: "Free Codebuff / Freebuff AI models.", + apiHint: "Token is authenticated against Codebuff upstream session pool.", + passthroughModels: true, + }, "charm-hyper": { id: "charm-hyper", alias: "charm-hyper", diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 4fc9db6fdd..1878b2ac41 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2164,6 +2164,29 @@ "stream": "https://freeinference.org/v1/chat/completions" } }, + "freebuff": { + "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://www.codebuff.com/api/v1", + "stream": "https://www.codebuff.com/api/v1" + } + }, "freemodel-dev": { "format": "openai", "headers": { diff --git a/tests/unit/freebuff-provider.test.ts b/tests/unit/freebuff-provider.test.ts new file mode 100644 index 0000000000..d67de75f97 --- /dev/null +++ b/tests/unit/freebuff-provider.test.ts @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FreebuffExecutor } from "../../open-sse/executors/freebuff.ts"; +import type { ExecuteInput } from "../../open-sse/executors/base.ts"; +import { freebuffProvider } from "../../open-sse/config/providers/registry/freebuff/index.ts"; +import { APIKEY_PROVIDERS_GATEWAYS } from "../../src/shared/constants/providers/apikey/gateways.ts"; +import { validateFreebuffProvider } from "../../src/lib/providers/validation.ts"; + +test("FreebuffExecutor: constructor initializes provider name correctly", () => { + const executor = new FreebuffExecutor(); + assert.equal(executor.getProvider(), "freebuff"); +}); + +test("FreebuffExecutor: returns 401 response when credentials are missing", async () => { + const executor = new FreebuffExecutor(); + const res = await executor.execute({ + model: "deepseek/deepseek-v4-flash", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: false, + credentials: { apiKey: "" }, + } as unknown as ExecuteInput); + + assert.equal(res.response.status, 401); + const data = (await res.response.json()) as { error: { message: string } }; + assert.match(data.error.message, /Freebuff Auth Token required/i); +}); + +test("freebuffProvider: registry entry has valid structure and catalog", () => { + assert.equal(freebuffProvider.id, "freebuff"); + assert.equal(freebuffProvider.format, "openai"); + assert.equal(freebuffProvider.executor, "freebuff"); + assert.equal(freebuffProvider.baseUrl, "https://www.codebuff.com/api/v1"); + assert.ok(Array.isArray(freebuffProvider.models)); + assert.ok(freebuffProvider.models.length >= 8); + + const flash = freebuffProvider.models.find((m) => m.id === "deepseek/deepseek-v4-flash"); + assert.ok(flash, "deepseek/deepseek-v4-flash must exist in freebuff models"); + assert.equal(flash?.supportsReasoning, true); + + const minimax = freebuffProvider.models.find((m) => m.id === "minimax/minimax-m3"); + assert.ok(minimax, "minimax/minimax-m3 must exist in freebuff models"); + assert.equal(minimax?.supportsVision, true); +}); + +test("APIKEY_PROVIDERS_GATEWAYS: freebuff gateway metadata is defined", () => { + const fb = APIKEY_PROVIDERS_GATEWAYS.freebuff; + assert.ok(fb, "freebuff must be in APIKEY_PROVIDERS_GATEWAYS"); + assert.equal(fb.id, "freebuff"); + assert.equal(fb.name, "Freebuff"); + assert.equal(fb.color, "#10B981"); + assert.equal(fb.hasFree, true); +}); + +test("validateFreebuffProvider: returns invalid when apiKey is empty", async () => { + const res = await validateFreebuffProvider({ apiKey: "" }); + assert.equal(res.valid, false); + assert.match(res.error || "", /Freebuff Auth Token required/i); +}); diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index abc46e6870..9276da2206 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -23,7 +23,8 @@ // gateways family to 228 measured on the tip; Puter retired (#10210) and chatanywhere restored // (base-reds round 3, #9985) are both included in that measurement; Cursor API (specialty-media, // #10729) brings it to 229; Token Kiosk (gateways, #10722) — merged in the same -// merge-train batch — independently bumped the gateways family too, landing at 231. +// merge-train batch — independently bumped the gateways family too, landing at 231; Freebuff +// (gateways, #10531) brings it to 232. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -52,12 +53,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 231 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 232 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 231); - assert.equal(new Set(keys).size, 231, "duplicate keys after spread-merge"); + assert.equal(keys.length, 232); + assert.equal(new Set(keys).size, 232, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 231. + // strict partition (every provider in exactly one), so the sum must be exactly 232. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -77,7 +78,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 231 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 231, "families must partition all 231 providers"); + assert.equal(famTotal, 232, "families must partition all 232 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => {