Compare commits

...
47 Commits
Author SHA1 Message Date
asepharyana 2bc9d67d26 merge: pull mhiqrambg/9router-mibp-version into master
Merge the MIBP fork (v1.0.14, synced to decolua v0.5.81) into our master
(0.5.86) at merge-base a8c9d380. Keep HEAD's infra policy (untracked
lockfile, mirror-configurable Dockerfile, decolua GHCR/DockerHub, README)
while absorbing the fork's engine features:

- feat(providers): freebuff provider + executor + OAuth + usage tracking
- feat(providers): cline free-tier models, Freebuff catalog sync
- feat(proxy-pools): pool egress geo probe, proxy-pool fitness + retry
- fix(usage): hide noAuth providers (devin-cli, mimo-free) from usage list
- test(harness): DATA_DIR isolation so tests never write the real DB
- fix(codebuddy-intl): probe token in connection test, OAuth by identity
- chore(guards): durable markers so fixes aren't silently dropped

Resolutions:
- registry/index.js regenerated deterministically (122 providers, alpha
  order). trae/windsurf/devin-cli stay hidden per HEAD security posture
  (no tool-calling / local-agent shell access) — not re-enabled.
- nonStreamingHandler: drop the generic unconditional unwrapDataEnvelope
  call; envelope unwrap stays scoped to clineEnvelope-quirk providers
  (unwrapClineEnvelope), fixing a latent mibp bug where non-opted-in
  providers ({success,data} bodies) were stripped.
- Drop fork-local Docker lockfile policy (package-lock.json, AGENTS.md,
  .npmrc verify scripts): this repo keeps package-lock untracked (nix
  build deploy). .npmrc (audit=false/fund=false) kept.
- Keep gitbook-pages workflow enabled (ours); mibp disabled it.
- Restore 13 upstream tests mibp deleted (they cover features we keep).

Verified: 2841 tests, 2687 pass, fail set byte-identical to HEAD (zero
new regressions); providers/alias/oauth baselines regenerated to merged
code and all green.
2026-09-23 11:44:58 +07:00
MUH. IQRAM BAHRING 25df5e6a9d chore(guards): add durable markers so fixes aren't silently dropped
Introduce AGENTS.md (root, primary agent instruction file) documenting six
hard-won fixes with explicit DO NOT / WHY, plus executable enforcement so a
future AI cannot delete or reintroduce them:

1. package-lock.json must be generated with npm 10 (Docker's npm 10.9.8).
   npm 11 drops the top-level @emnapi/core + @emnapi/runtime entries npm 10
   needs, breaking the tag-triggered Docker build at `npm ci` (happened on
   v1.0.14). Add scripts/verify-lockfile-npm10.mjs + .npmrc + a Dockerfile
   fail-fast check + a CI step + tests/unit/lockfile-npm10-guard.test.js.
   Also re-fix the lockfile itself (regenerated with npm 10.9.8).
2. Tests must never write to the real ~/.9router DB (isolateDataDir).
3. Hidden providers must not leak into Usage (usageProviders !p.hidden).
4. codebuddy-intl connection test + OAuth identity.
5. Fork-only features that must survive upstream syncs.
6. Upstream sync procedure.

Each marker cross-references AGENTS.md and the covering test. CLAUDE.md now
points to AGENTS.md at the top. Verified: build ok, guard script passes,
full suite leaves the real DB count unchanged (38), 0 new regressions.
2026-09-19 12:52:19 +08:00
MUH. IQRAM BAHRING 9c37af90a9 test(harness): isolate DATA_DIR so tests never write to the real DB
Root cause of fake connections in Usage (zed-live-*@example.com,
guard-*@example.com, zed "Account N", kimchi-nope): route-level tests
(zed-live-models, zed-native-auth) call createProviderConnection, which
persists to $DATA_DIR/db/data.sqlite. With DATA_DIR unset — the default
for `npx vitest run` — that resolved to the user's real ~/.9router DB,
appending test rows on every run. Both files documented "RUN WITH AN
ISOLATED DB" but never enforced it.

Add tests/setup/isolateDataDir.js (wired via vitest setupFiles) that
points DATA_DIR at a throwaway temp dir before src/lib/dataDir.js is
imported. Opt out with RUN_REAL=1 or an explicit DATA_DIR (used by the
*.real.test.js suites that read live credentials).

Verified: a full suite run now leaves the real DB byte-count unchanged;
new guard test tests/unit/test-data-dir-isolation.test.js locks it in.
2026-09-19 12:37:57 +08:00
MUH. IQRAM BAHRING 9d7821bdcc fix(usage): stop leaking hidden noAuth providers (devin-cli, mimo-free)
The Usage page auto-adds every noAuth free provider so connectionless
providers (opencode) still appear. It did not filter the registry's
hidden flag, so devin-cli and mimo-free — both category:"free" with
noAuth:true and hidden:true — showed up in Usage despite having no
connection and being absent from the Providers page (which does filter
hidden).

Extract the list assembly into buildUsageProviderList (shared/utils/
usageProviders.js) and skip hidden free providers there. Behavior for
visible noAuth providers (opencode) and dedup of active connections is
unchanged; covered by tests/unit/usage-provider-list.test.js.
2026-09-19 12:21:14 +08:00
MUH. IQRAM BAHRING 604b4d85d5 fix(codebuddy-intl): probe token in connection test + name OAuth by identity
Two bugs on codebuddy-intl connections:

1. Test Connection always failed with "Provider test not supported":
   codebuddy-intl was missing from OAUTH_TEST_CONFIG, so testOAuthConnection
   bailed before probing. Add a real probe against the Keycloak realm's
   userinfo endpoint (URL derived from the token's iss claim), and wire
   refreshable so an expired token is rotated via refreshCodebuddyIntlToken.

2. OAuth logins were named "Account N" with no email: mapTokens returned no
   identity, even though the access token is a Keycloak JWT carrying
   email/name claims. Extract email + displayName in mapTokens (new shared
   extractDisplayNameFromAccessToken helper) so fresh logins are named and
   deduped by identity.

Also add a run-once backfill (backfillCodeBuddyIntlIdentity) invoked from
GET /api/providers and /api/providers/client to self-heal existing rows
(backfill email/displayName, rename the generic "Account N" placeholder).

Verified live: the real connection now returns valid:true and the row is
renamed to the account email.
2026-09-19 12:14:21 +08:00
MUH. IQRAM BAHRING 1884e3a063 test(cline): align auth-header test with upstream WorkOS-JWT-only prefixing
Upstream f6e7cabe stopped workos:-prefixing opaque tokens (ClinePass API
keys like clp_...), so getClineAccessToken now only prefixes WorkOS JWTs.
The fork's test asserted the old unconditional-prefix behavior.
2026-09-19 11:47:15 +08:00
MUH. IQRAM BAHRING 9342aa5fb9 chore(version): bump app version to 1.0.14 (upstream v0.5.81 sync) 2026-09-19 11:36:17 +08:00
MUH. IQRAM BAHRING 0ac771bad8 merge: sync upstream v0.5.81 into MIBP fork
# Conflicts:
#	.gitignore
#	Dockerfile
#	open-sse/handlers/chatCore.js
#	open-sse/providers/registry/cline.js
#	open-sse/providers/registry/index.js
#	open-sse/services/usage.js
#	open-sse/utils/streamHandler.js
#	package.json
#	src/app/(dashboard)/dashboard/profile/page.js
#	src/app/(dashboard)/dashboard/providers/[id]/page.js
2026-09-19 11:35:34 +08:00
MUH. IQRAM BAHRING b0505067b2 feat(providers): add Cline free-tier models and sync Freebuff catalog
- Cline: 6 free models, cline-cli product headers, API-key auth,
  {data} envelope unwrap, workos: prefix handling
- Freebuff: muse-spark 1.3 → 1.2 (upstream withdrawal 2026-09-07),
  DeepSeek V4.1 Flash rename
2026-09-11 12:21:35 +08:00
MUH. IQRAM BAHRING 8b34cfaa44 chore(version): update app version to 1.0.12 2026-09-07 22:28:20 +08:00
MUH. IQRAM BAHRING 9c17c5f96c feat(freebuff): enhance Freebucks handling and pricing logic across services 2026-09-07 22:26:28 +08:00
MUH. IQRAM BAHRING eedfdf982b chore(version): align app version to fork release cadence (1.0.11) 2026-09-06 00:45:19 +08:00
MUH. IQRAM BAHRING 06c66d8df8 ci(docker): pin node:22-alpine by digest so npm cannot drift under npm ci 2026-09-05 23:55:33 +08:00
MUH. IQRAM BAHRING a2c6187aed chore(deps): resync lockfile with npm 10 (docker npm ci missing @emnapi 1.11.3) 2026-09-05 23:55:33 +08:00
MUH. IQRAM BAHRING c378f2dc90 Merge remote-tracking branch 'upstream/master' 2026-09-05 23:20:53 +08:00
MUH. IQRAM BAHRING 9cd61d585c feat(freebuff): update model catalog + fable offer claims 2026-09-05 23:18:42 +08:00
MUH. IQRAM BAHRING cdbdcd3448 ci(docker): pin actions by SHA, add concurrency, exclude tests from build context 2026-08-30 23:47:01 +08:00
MUH. IQRAM BAHRING eaf204b83c Merge remote-tracking branch 'upstream/master'
# Conflicts:
#	open-sse/handlers/chatCore.js
#	open-sse/providers/registry/index.js
#	open-sse/services/usage.js
2026-08-30 23:23:48 +08:00
MUH. IQRAM BAHRING 2d6673db61 docs: rewrite README for MIBP fork (docker + manual install) 2026-08-15 08:35:36 +08:00
MUH. IQRAM BAHRING 470c8ca72c fix(docker): regenerate platform-complete lockfile (npm 10, clean resolve) 2026-08-15 08:14:03 +08:00
MUH. IQRAM BAHRING 76b139b32c fix(docker): restore lockfile (npm 10) + npm ci for reliable builds 2026-08-15 08:07:59 +08:00
MUH. IQRAM BAHRING 3d10fd9611 chore(docker): follow upstream — untrack package-lock, use npm install 2026-08-15 04:25:18 +08:00
MUH. IQRAM BAHRING c895410510 chore(deps): sync package lock 2026-08-15 04:11:54 +08:00
MUH. IQRAM BAHRING f555236545 merge: update from decolua/9router upstream (SAML, providers, opencode session headers) 2026-08-15 03:54:03 +08:00
MUH. IQRAM BAHRING df8c4ccb81 fix: improve provider routing and assignments 2026-08-15 03:47:18 +08:00
MUH. IQRAM BAHRING d00205511a feat(docs): add screenshots + beginner install guide, MIBP Edition credits 2026-08-07 12:48:39 +08:00
MUH. IQRAM BAHRING 7929464427 fix: restore platform-complete package-lock for linux npm ci 2026-08-07 09:32:23 +08:00
MUH. IQRAM BAHRING 56f46aa433 chore(ci): disable gitbook pages workflow for fork, add healthcheck + geo env docs
gitbook-pages.yml deployed to upstream org's 9router.github.io which a fork
cannot (no GH_PAGES_DEPLOY_KEY) — renamed to .yml.disabled. Dockerfile gains
a /api/health HEALTHCHECK. .env.example documents the new pool geostrobes
env knobs (POOL_GEO_PROBE_DISABLED, GEO_PROBE_URL).
2026-08-07 08:35:49 +08:00
MUH. IQRAM BAHRING 3844f04cb5 fix(profile): move Check Size Memory button beside Import Backup 2026-08-07 08:20:27 +08:00
MUH. IQRAM BAHRING 1f4813c786 feat(profile): Check Size Memory button + English geo-probe hint
New /api/system/memory reports db + data-dir sizes and in-memory state
(fitness/geo registries, freebuff session/cooldown counts); profile page
'Local Mode' card gets a Check Size Memory button rendering the summary.
Geo-probe toggle hint localized to English.
2026-08-07 08:11:44 +08:00
MUH. IQRAM BAHRING 37d03aa0d5 feat(proxy-fitness): on/off toggle for egress geo probe
Header toggle in Proxy Fitness persists settings.poolGeoProbeEnabled
(default on); the probe scheduler reads it each pass and skips when off.
The env POOL_GEO_PROBE_DISABLED remains a hard override. Smart rotation is
unaffected — it runs off the fitness registry from real request failures.
2026-08-07 08:01:55 +08:00
MUH. IQRAM BAHRING a443834f53 feat(proxy-fitness): multi-endpoint egress geo probe chain
Try ipwho.is -> ip-api.com -> ipapi.co -> ipinfo.io in order so a
rate-limited/broken provider falls through to the next; ipinfo (most
quota-bound) is last. Normalize each payload to {ip,country,region,city,org},
keep 15s timeout per endpoint, and support GEO_PROBE_URL to replace the
chain with a single custom endpoint.
2026-08-07 07:45:54 +08:00
MUH. IQRAM BAHRING 3c4bd4f1e5 feat(proxy-fitness): throttle egress geo probe + aggregate failure log
Refuse re-probing failures too fast: 500/429 failures get a 2h backoff,
other errors 30m (flapping relays/quota stops hammering ipinfo every pass,
passes every 30 min instead of 15). One-line summary per pass
(geo N/M · fail: rate×a server×b) replaces the per-pool error wall.
New env POOL_GEO_PROBE_DISABLED=1 turns the feature off.
2026-08-07 07:16:29 +08:00
MUH. IQRAM BAHRING c1bbe446de chore: drop full error stack traces from chat error logs
Shorten the ✗ ERROR 502 / stream ERROR lines to the message only; the
multi-KB next-dev stack trace polluted the console on every provider error.
2026-08-07 06:39:54 +08:00
MUH. IQRAM BAHRING fa679a676d docs(cloud): raise nginx client_max_body_size for large-context payloads
nginx default 1m rejects multi-MB chat bodies (1M-token prompts) with
413 before they reach 9Router; set 128m in the documented site config.
2026-08-07 05:40:18 +08:00
MUH. IQRAM BAHRING 2a1e192734 feat(api): advertise context_window on /v1/models from capabilities 2026-08-07 04:56:40 +08:00
MUH. IQRAM BAHRING 041e039793 chore: strip per-request DEBUG logs from chat path
Remove [DBG:STREAM] chunk/pipe/EOF instrumentation and [DBG:SSE] flush +
fetch success lines that spam the dev console every request. Keep the stream
stall watchdog and the fetch-error (✖) diagnostics.
2026-08-07 04:24:54 +08:00
MUH. IQRAM BAHRING ad43a267ac chore: re-sync package-lock 2026-08-07 04:04:35 +08:00
MUH. IQRAM BAHRING a0bf2e3f96 fix(token-refresh): stop dead-token retry loop, lift block on refresh success
Unrecoverable refresh errors (invalid_grant/invalid_request) are persisted as
a refreshBlocked marker so the background scheduler stops hammering the
provider every 5 minutes and surfaces re-login required; the marker is lifted
automatically when a later refresh succeeds. Cooldown maps gained lazy pruning.
2026-08-07 03:51:38 +08:00
MUH. IQRAM BAHRING b75d665fa5 feat(proxy-fitness): egress geo probe + unstable detection + state sweeper
Background probe fetches ipinfo through each pool itself (provider-agnostic
transport), fills an egress IP/country cache (TTL 1h, 8-IP history) and flags
flapping relays as unstable. Periodic state sweeper (10 min) prunes expired
fitness marks, stale geo/ip-history and Freebuff session/cooldown state;
schedulers skip non-server runtimes. Unit tests for the geo cache.
2026-08-07 03:51:29 +08:00
MUH. IQRAM BAHRING fd22290a34 feat(proxy-fitness): pool fitness registry + Smart rotation + pool-scoped retry
Pool/IP fitness registry (globalThis-backed, provider::model scopes, 5-min
cooldown, provider::* wildcard) fed by pool-scoped failures: freebuff
limited-IP / model-locked gates and opencode free per-IP limits. chatCore
retries a failed pool via another pool without locking the account; new
Smart rotation strategy (per-connection + no-auth providers) skips unfit
pools. Proxy Fitness dashboard page: active-block table with provider/IP
filters, per-record Clear and provider-scoped Clear All; egress column via
pool geo enrichment. Unit tests for the registry.
2026-08-07 03:51:19 +08:00
MUH. IQRAM BAHRING 90ff8191ff fix: lock Docker dependencies and remove test credential 2026-08-06 04:10:58 +08:00
MUH. IQRAM BAHRING d05b84d377 chore: prepare fork docker deployment 2026-08-06 03:11:31 +08:00
MUH. IQRAM BAHRING eca3b9c3dc fix: add FREEBUFF_CONFIG export to oauth constants
- Export FREEBUFF_CONFIG from src/lib/oauth/constants/oauth.js
- Fixes build error: 'Export FREEBUFF_CONFIG doesn't exist in target module'
2026-08-06 01:05:20 +08:00
MUH. IQRAM BAHRING de5fb415a7 feat: register Freebuff provider in all index files
- Add Freebuff to providers registry (open-sse/providers/registry/index.js)
- Register FreebuffExecutor (open-sse/executors/index.js)
- Add Freebuff OAuth config (src/lib/oauth/providers/index.js)
- Register Freebuff usage handler (open-sse/services/usage.js)

This enables Freebuff provider to appear in dashboard and be fully functional.
2026-08-06 00:54:04 +08:00
MUH. IQRAM BAHRING b15b0c930a docs: add upstream sync instructions to README 2026-08-06 00:35:29 +08:00
MUH. IQRAM BAHRING 8343d54bf2 feat: add Freebuff provider and fix dompurify security vulnerabilities
- Add Freebuff provider integration (executor, registry, OAuth, usage tracking)
- Upgrade monaco-editor to ^0.56.0
- Add dompurify ^3.4.13 with override to fix security vulnerabilities
- Add Freebuff provider icon and test files
2026-08-06 00:30:06 +08:00
96 changed files with 7709 additions and 1787 deletions
+5
View File
@@ -1,6 +1,11 @@
# VCS
.git
**/.git
.github
# Test suites (not needed for the image build)
tests
**/tests
# Editor
.vscode
+6
View File
@@ -39,3 +39,9 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com
# Currently unused by application runtime (kept as reference)
# INSTANCE_NAME=9router
# Proxy egress geo probe (Proxy Fitness "Geo probe" toggle makes this a runtime
# setting too). These env vars are the hard override / advanced knobs for the
# background scheduler that samples each pool's egress IP+country.
# POOL_GEO_PROBE_DISABLED=1 # 1 = never run the geo probe
# GEO_PROBE_URL= # optional single custom endpoint (replaces the built-in 4-endpoint chain)
+1
View File
@@ -26,6 +26,7 @@ product
# misc
.DS_Store
*.pem
.vscode
# debug
npm-debug.log*
+17
View File
@@ -0,0 +1,17 @@
# MIBP fork — npm behavior pin.
#
# DO NOT DELETE. See AGENTS.md §1.
#
# The Docker image (node:22-alpine, pinned by digest) ships npm 10.9.8 and runs
# `npm ci` against the committed package-lock.json. Regenerating the lockfile
# with npm 11+ drops the top-level @emnapi/core + @emnapi/runtime entries npm 10
# needs, which breaks the tag-triggered Docker build ("Build and Push Docker
# Image") at `npm ci`.
#
# Regenerate the lockfile with npm 10 only:
# npx -y npm@10.9.8 install --package-lock-only
# node scripts/verify-lockfile-npm10.mjs
#
# Keep install output quiet; never let audit/fund noise hide a lockfile error.
audit=false
fund=false
+6
View File
@@ -2,6 +2,12 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> ⚠️ **MIBP fork — read [`AGENTS.md`](AGENTS.md) first.** It lists fixes that are
> easy to silently delete or reintroduce (npm-10 lockfile rule, test DB
> isolation, hidden-provider filtering, codebuddy-intl behavior, fork-only
> features that must survive upstream syncs). Do not remove anything listed
> there without explicit confirmation.
## What this is
9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync.
-1315
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
services:
9router:
image: decolua/9router:latest
image: mhiqrambhrng/9router-mibp-version:latest
container_name: 9router
restart: always
ports:
+4
View File
@@ -247,6 +247,10 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Allow large request bodies — big context windows (e.g. 1M token prompts)
# produce multi-MB chat payloads; nginx default is 1m and would 413 them.
client_max_body_size 128m;
# Proxy to 9Router
location / {
proxy_pass http://localhost:3000;
+4
View File
@@ -247,6 +247,10 @@ server {
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Cho phép body lớn — prompt ngữ cảnh 1M token sinh payload vài MB;
# mặc định nginx là 1m và sẽ trả 413.
client_max_body_size 128m;
# Proxy to 9Router
location / {
proxy_pass http://localhost:3000;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 KiB

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
-5
View File
@@ -139,8 +139,6 @@ export class BaseExecutor {
try {
const bodyStr = JSON.stringify(transformedBody);
const fetchT0 = Date.now();
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`);
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
@@ -148,9 +146,6 @@ export class BaseExecutor {
signal: mergedSignal
}, proxyOptions);
clearTimeout(connectTimer);
const ct = response.headers?.get?.("content-type") || "";
const cl = response.headers?.get?.("content-length") || "?";
dbg("FETCH", `${this.provider.toUpperCase()} ← ${response.status} | ttft=${Date.now() - fetchT0}ms | ct=${ct} | cl=${cl}`);
if (await tryRetry(urlIndex, response.status, `status ${response.status}`, response)) { urlIndex--; continue; }
+8 -3
View File
@@ -25,8 +25,13 @@ function setAuth(headers, spec, token) {
// Resolve auth onto headers from a descriptor.
function applyAuth(headers, desc, credentials) {
if (desc.combined) {
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined")
setAuth(headers, desc, credentials.apiKey || credentials.accessToken);
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined") —
// unless the descriptor opts into preserveHookAuth: a hook then owns the
// Authorization value (e.g. Cline needs workos:-prefixed OAuth tokens but
// plain API keys, which a single merged token can't express).
if (!(desc.preserveHookAuth && headers[desc.header])) {
setAuth(headers, desc, credentials.apiKey || credentials.accessToken);
}
if (desc.anthropicVersion && !headers["anthropic-version"]) headers["anthropic-version"] = ANTHROPIC_API_VERSION;
return;
}
@@ -40,7 +45,7 @@ function applyAuth(headers, desc, credentials) {
const HEADER_HOOKS = {
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)),
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)),
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken, {}, { isApiKey: !!c.apiKey })),
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
};
+827
View File
@@ -0,0 +1,827 @@
import crypto from "node:crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { dbg } from "../utils/debugLog.js";
import {
FETCH_CONNECT_TIMEOUT_MS,
DEFAULT_RETRY_CONFIG,
resolveRetryEntry,
} from "../config/runtimeConfig.js";
import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js";
/**
* Freebuff Executor — OpenAI-compatible chat completions on
* https://www.codebuff.com/api/v1/chat/completions (the Codebuff/Freebuff backend).
*
* Wire shape mirrors the official CLI exactly. The CLI (Vercel AI SDK with the
* codebuff openai-compatible provider) builds `providerOptions.codebuff` =
* { codebuff_metadata, provider } and the provider spreads those entries at
* the TOP LEVEL of the request body — i.e. the body is:
* { model, messages, codebuff_metadata: { run_id, client_id, cost_mode,
* freebuff_instance_id? }, provider: { allow_fallbacks } }
* NOT nested under a `codebuff` object (the backend rejects the nested shape
* with 400 "No runId found in request body").
*
* The run_id is not a free-form uuid: the backend resolves it against its
* agent-run store and rejects unknown ids with 400 "runId Not Found". So every
* chat request first registers a run via POST /api/v1/agent-runs
* ({ action:"START", agentId, ancestorRunIds:[] }) → { runId }, and that id is
* what goes in codebuff_metadata.run_id. The free tier additionally gates on a
* session: POST /api/v1/freebuff/session with an `x-freebuff-model` header
* claims a row (bound to one model, ~1h); its instance id must ride along as
* codebuff_metadata.freebuff_instance_id.
*/
const SESSION_PATH = "/api/v1/freebuff/session";
const RUN_PATH = "/api/v1/agent-runs";
const SESSION_DEFAULT_TTL_MS = 60 * 60 * 1000; // active sessions live ~1h
// Chat statuses that mean our claimed session is stale and must be re-claimed
// before retrying (mirrors the CLI's FreebuffGateErrorKind statuses).
const SESSION_STALE_CODES = new Set([428, 409, 410]);
// Models the backend runs as a CAPACITY-LIMITED OFFER rather than a standing
// picker row. Claude Fable 5 is not in the client catalog at all: the server
// advertises it per-session-response (`limitedModelOffers`) only while its
// shared wave pool has sessions left, and a request without a live offer is
// refused. A claim must therefore peek at the current offers first instead of
// POSTing blind (mirrors the CLI: the "Claude Fable 5 · N of M left" row only
// renders from that payload). Offer state is per-account and cached briefly —
// the pool can reopen at any time, so a closed offer must NOT set a long
// cooldown.
const OFFER_GATED_MODELS = new Set(["anthropic/claude-fable-5"]);
const OFFER_CACHE_TTL_MS = 45_000;
// The free tier rejects requests whose first system message doesn't open with
// the canonical Freebuff CLI root prompt (server gate
// requestHasFreebuffSystemMarker → 403 free_mode_cli_required). The check is a
// byte-exact prefix test on position 0, so we prepend the canonical opening.
// Same anti-abuse pattern as mimo-free's MIMO_SYSTEM_MARKER injection.
const FREEBUFF_SYSTEM_MARKER = "You are Buffy, the strategic coding assistant.";
// Canonical openings accepted by the server gate (mirrors the CLI's
// FREEBUFF_ROOT_SYSTEM_PROMPT_OPENINGS). The check is a byte-exact prefix on
// the first message, so our injected marker must be one of these verbatim.
const FREEBUFF_ROOT_SYSTEM_OPENINGS = [
"You are Buffy, the strategic coding assistant.",
"You are Buffy, the Freebuff Cloud project planner.",
"You are Buffy, a strategic assistant that orchestrates complex coding tasks through specialized sub-agents.",
];
// Ensure messages[0] opens with a canonical Freebuff root prompt (idempotent).
function injectFreebuffMarker(body) {
const messages = body?.messages;
if (!Array.isArray(messages) || messages.length === 0) return body;
const first = messages[0];
if (first?.role === "system" && typeof first.content === "string") {
const trimmed = first.content.trimStart();
if (FREEBUFF_ROOT_SYSTEM_OPENINGS.some((opening) => trimmed.startsWith(opening))) return body; // already marked
// Prepend the canonical opening to the existing system prompt so it stays
// the first thing the model reads (keep the rest of the messages intact).
return {
...body,
messages: [{ ...first, content: `${FREEBUFF_SYSTEM_MARKER}\n\n${first.content}` }, ...messages.slice(1)],
};
}
// No leading system message — insert one with the canonical opening.
return { ...body, messages: [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }, ...messages] };
}
// The backend's foreign_toolset gate rejects any tool-calling request whose
// toolset lacks the CLI's `end_turn` tool with a misleading 404 "No endpoints
// found for {model}" (verified live 2026-08-14; the freebuff-proxy bridge
// works around the same gate by injecting this definition). Every request that
// declares tools must carry it or the router finds no serving endpoint.
const END_TURN_TOOL = {
type: "function",
function: {
name: "end_turn",
description: "Signal the end of the current task.",
parameters: { type: "object", properties: {} },
},
};
function injectEndTurnTool(body) {
const tools = body?.tools;
if (!Array.isArray(tools) || tools.length === 0) return body;
const hasEndTurn = tools.some(
(t) => t?.function?.name === "end_turn",
);
if (hasEndTurn) return body;
return { ...body, tools: [...tools, END_TURN_TOOL] };
}
// Freebuff root agent id per model (mirrors the CLI's
// FREEBUFF_CLI_BASE3_AGENT_ID_BY_MODEL — the CLI harness moved from base2 to
// base3, and the backend can return 404 "No endpoints found" for the old
// base2 roots during the transition).
//
// Withdrawn upstream models (deepseek-v4-pro, minimax-m3, stealth/ox-alpha,
// google/gemini-3.8-flash, meta/muse-spark-1.3-contributor) are deliberately
// absent: no new session can be admitted on them, so mapping them would only
// hide a dead pick behind a wrong root. z-ai/glm-5.2 stays mapped
// (referral-earned accounts can still run it) even though it is not a
// standing picker row.
const FREE_ROOT_AGENT_BY_MODEL = {
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
"z-ai/glm-5.2": "base3-free-glm",
"z-ai/glm-5.3-flash": "base3-free-glm-5-3-flash",
"mimo/mimo-v2.5": "base3-free-mimo",
"openai/gpt-5.6-luna": "base3-free-luna",
"upstage/solar-pro4": "base3-free-solar-pro4",
"meta/muse-spark-1.2-contributor": "base3-free-muse-spark",
"anthropic/claude-fable-5": "base3-free-fable",
};
// Per-token+model session cache (in-memory; keyed so multi-account setups
// don't share one session row). Re-claims are driven by the cache expiring or
// by a 428 from chat — no early re-claim, so we never POST /session while our
// own row is still active (which could come back as a spurious model_locked).
// All state lives on globalThis so Next dev (Turbopack) bundles share ONE copy.
const FB_STATE_KEY = "__9routerFreebuffState__";
const fbState = (globalThis[FB_STATE_KEY] ??= {
sessionCache: new Map(), // `${token}::${model}` -> { instanceId, expiresAt }
inflight: new Map(), // dedupe concurrent claims for the same key
modelLockCooldowns: new Map(), // `${token}::${model}` -> expiresAt (ms)
poolLimitCooldowns: new Map(), // `${proxyKey}::${model}` -> expiresAt (ms)
offerCache: new Map(), // `${token}` -> { fetchedAt, offers: [] } (limited-offer rows)
});
const sessionCache = fbState.sessionCache;
const inflight = fbState.inflight;
const modelLockCooldowns = fbState.modelLockCooldowns;
const poolLimitCooldowns = fbState.poolLimitCooldowns;
const offerCache = fbState.offerCache;
const MODEL_LOCK_COOLDOWN_MS = 10 * 60 * 1000; // session bound to another model (~1h) — re-check every 10 min
const POOL_LIMITED_COOLDOWN_MS = 5 * 60 * 1000; // IP tier refuses this model — try a different pool/relay
// Cooldown maps need pruning: expired entries are cleared on write (sweep) and
// on read, so long-running servers don't accumulate one entry per (account,model)
// / (proxy,model) forever.
function setCooldown(map, key, until) {
const now = Date.now();
for (const [k, v] of map) {
if (v <= now) map.delete(k);
}
map.set(key, until);
}
function getCooldown(map, key) {
const until = map.get(key);
if (until == null) return null;
if (until <= Date.now()) {
map.delete(key);
return null;
}
return until;
}
function proxyKeyOf(proxyOptions) {
return proxyOptions?.vercelRelayUrl || proxyOptions?.connectionProxyUrl || "direct";
}
function sessionGateFromText(text) {
let parsed = {};
try { parsed = JSON.parse(String(text || "")); } catch { parsed = {}; }
return classifySessionGate(parsed.error || parsed.error_type || "", parsed.message || "", parsed.currentModel || null);
}
// Parse a 409/428/410 body into { kind, currentModel }. `msg` may be a whole
// error string containing a JSON tail (requestSession errors embed the body).
function sessionGateFromError(error) {
const msg = String(error?.message || "");
const start = msg.indexOf("{");
if (start < 0) return null;
try {
const parsed = JSON.parse(msg.slice(start));
return classifySessionGate(parsed.error || "", parsed.message || "", parsed.currentModel || null);
} catch {
return null;
}
}
function classifySessionGate(code, message, currentModel) {
if (code === "session_superseded") return { kind: "superseded" };
if (code === "model_locked") return { kind: "model_locked", currentModel };
// session_model_mismatch with the limited-tier message is an IP-tier refusal;
// without it (or unknown) treat it as a model lock so we don't reclaim in a loop.
if (code === "session_model_mismatch") {
return /limited/i.test(String(message || ""))
? { kind: "limited_ip" }
: { kind: "model_locked", currentModel };
}
return { kind: "stale" }; // 428/410/unknown → reclaim
}
// Applies cooldowns and throws for non-reclaimable gates. Never returns for them.
function throwSessionGateError(gate, { token, model, proxyKey, poolId, log }) {
if (gate.kind === "model_locked") {
const until = Date.now() + MODEL_LOCK_COOLDOWN_MS;
setCooldown(modelLockCooldowns, `${token}::${model}`, until);
const label = gate.currentModel ? `"${gate.currentModel}"` : "another model";
const err = new Error(
`Freebuff session is locked to ${label} — it cannot serve ${model}. End the session on freebuff.com or wait for it to expire (~1h).`,
);
err.status = 409;
err.resetsAtMs = until;
log?.warn?.("AUTH", `Freebuff model_locked (session=${label}, requested=${model}) — model cooldown ${MODEL_LOCK_COOLDOWN_MS / 60000}min`);
throw err;
}
if (gate.kind === "limited_ip") {
const until = Date.now() + POOL_LIMITED_COOLDOWN_MS;
setCooldown(poolLimitCooldowns, `${proxyKey}::${model}`, until);
const scope = `freebuff::${model}`;
if (poolId) markPoolUnfit(poolId, scope, until, "limited_ip");
// Pool-scoped, not account-scoped: the caller retries via another pool
// instead of locking the account (resetsAtMs intentionally absent).
const err = new Error(
`Freebuff limited-mode IP rejected ${model} — this IP only allows DeepSeek V4 Flash / MiMo 2.5. Use a full-access proxy or a different model.`,
);
err.status = 409;
err.poolScoped = { poolId, scope, reason: "limited_ip" };
log?.warn?.("AUTH", `Freebuff limited-IP refused ${model} (proxy=${proxyKey.slice(0, 40)}…) — cooldown ${POOL_LIMITED_COOLDOWN_MS / 60000}min`);
throw err;
}
}
function sessionOrigin() {
return new URL(PROVIDERS.freebuff.baseUrl).origin; // https://www.codebuff.com
}
function sessionCacheKey(token, model) {
return `${token}::${model}`;
}
function rootAgentIdForModel(model) {
return FREE_ROOT_AGENT_BY_MODEL[model] || "base2-free";
}
// Retry transient network errors (ECONNRESET, TLS reset, …) on the session/
// run API calls — mirrors the CLI's fetchWithRetry. Only fetch-level throws
// are retried; HTTP error responses are returned as-is.
//
// The timeout signal is built per attempt: a single shared
// AbortSignal.timeout() would stay aborted forever after it fires, silently
// turning attempts 2..n into instant no-op rejections.
async function fetchWithNetworkRetry(url, options, proxyOptions, attempts = 3, timeoutMs = FETCH_CONNECT_TIMEOUT_MS) {
let lastError;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
const opts = { ...options, signal: AbortSignal.timeout(timeoutMs) };
return await proxyAwareFetch(url, opts, proxyOptions);
} catch (error) {
lastError = error;
if (attempt + 1 < attempts) {
await new Promise((resolve) => setTimeout(resolve, 750));
}
}
}
throw lastError;
}
async function requestSession(token, model, proxyOptions) {
// Offer-gated models (Fable) refuse claims while their wave pool is closed —
// checked before the POST so a closed offer never burns a claim attempt.
await guardOfferClaim(token, model, proxyOptions);
const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": "codebuff-cli/0.0.138",
"x-freebuff-model": model,
},
}, proxyOptions);
let data = {};
try { data = await response.json(); } catch { data = {}; }
if (response.status === 401) {
const err = new Error("Freebuff session auth failed (401) — re-login in the dashboard");
err.status = 401;
throw err;
}
const status = data?.status;
const GATE_MESSAGES = {
country_blocked: "Freebuff is not available in your region (country blocked).",
banned: "Your Freebuff account has been banned.",
ip_capped: "Freebuff IP cap reached — try again later.",
rate_limited: "Freebuff session limit reached for this model — try again later.",
spend_limited: "Freebuff spend limit reached — add credits or wait for the window to reset.",
model_locked: "Freebuff session is locked to another model — end it in the CLI or wait for it to expire.",
model_unavailable: "This model is not available on Freebuff right now.",
premium_slot_taken: "Freebuff premium slot is taken — try another model.",
};
// Gate statuses ride BOTH 200 (pre-join refusals) and 4xx — the backend
// sends spend_limited/rate_limited as HTTP 429 with the gate in the body.
// Handle them BEFORE the generic !response.ok throw so exhaustion carries
// resetsAtMs (skip-until-reset) instead of a bare status.
if (GATE_MESSAGES[status]) {
const err = new Error(data?.message ? `${GATE_MESSAGES[status]} ${data.message}` : GATE_MESSAGES[status]);
// Freebucks / session-allowance exhaustion is a hard stop until the daily
// Pacific reset — mark the account unavailable until then so accountFallback
// SKIPS it for the rest of the day instead of retrying every 30s and getting
// refused repeatedly. resetsAtMs is honored by markAccountUnavailable;
// freebuff bypasses the generic 30-min cap (see auth.js).
if (status === "rate_limited" || status === "spend_limited") {
const resetAtMs = Date.parse(data?.resetAt || "");
if (Number.isFinite(resetAtMs) && resetAtMs > Date.now()) {
err.resetsAtMs = resetAtMs;
err.status = 429;
} else {
const retryAfterMs = Number(data?.retryAfterMs);
if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
err.resetsAtMs = Date.now() + retryAfterMs;
err.status = 429;
}
}
}
throw err;
}
if (!response.ok) {
const err = new Error(`Freebuff session request failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`);
err.status = response.status;
throw err;
}
if (status === "active") {
const parsedExp = Date.parse(data.expiresAt || "");
const entry = {
instanceId: data.instanceId,
expiresAt: Number.isFinite(parsedExp) ? parsedExp : Date.now() + SESSION_DEFAULT_TTL_MS,
};
sessionCache.set(sessionCacheKey(token, model), entry);
return { instanceId: data.instanceId, status: "active" };
}
if (status === "none") {
// Not session-gated right now — proceed without an instance id; a 428 on
// chat tells us the admission gate actually requires a session.
return { instanceId: null, status: "none" };
}
throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`);
}
// Fetch the account's current limited-model offers (GET — never claims).
// Cached per token for OFFER_CACHE_TTL_MS: the wave pool changes on server
// time, not ours, and a claim only needs to know "is it open right now".
async function fetchSessionOffers(token, proxyOptions) {
const now = Date.now();
const cached = offerCache.get(token);
if (cached && now - cached.fetchedAt < OFFER_CACHE_TTL_MS) {
return cached.offers;
}
const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "codebuff-cli/0.0.138",
Accept: "application/json",
},
}, proxyOptions);
let data = {};
try { data = await response.json(); } catch { data = {}; }
if (response.status === 401) {
const err = new Error("Freebuff session auth failed (401) — re-login in the dashboard");
err.status = 401;
throw err;
}
if (!response.ok) {
const err = new Error(`Freebuff offer check failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`);
err.status = response.status;
throw err;
}
const offers = Array.isArray(data?.limitedModelOffers)
? data.limitedModelOffers.filter((o) => o && typeof o.model === "string")
: [];
offerCache.set(token, { fetchedAt: now, offers });
return offers;
}
// For an offer-gated model (Fable), refuse the claim BEFORE the POST when the
// backend is not currently advertising it. Returns the matching offer when the
// claim may proceed. Throws a plain Error (no JSON tail) so the executor's
// sessionGateFromError stays null and the cooldown maps are never touched —
// a closed offer is availability, not a lock, and the pool can reopen any time.
async function guardOfferClaim(token, model, proxyOptions) {
if (!OFFER_GATED_MODELS.has(model)) return null;
const offers = await fetchSessionOffers(token, proxyOptions);
const offer = offers.find((o) => o.model === model);
if (!offer || Number(offer.remaining) <= 0) {
const err = new Error(
`Claude Fable 5 is not being offered right now — it is a capacity-limited trial served in waves, and freebuff's shared Fable pool is currently empty. Watch the official freebuff CLI for the "Claude Fable 5 · N of M left" row, or retry later.`,
);
err.status = 409;
err.code = "offer_closed";
throw err;
}
const userLeft = Number(offer.userRemaining);
if (Number.isFinite(userLeft) && userLeft <= 0) {
const resetAt = Date.parse(offer.userResetAt || "");
const err = new Error(
`Your Freebuff account has used its Claude Fable 5 sessions for today (pool: ${offer.remaining} of ${offer.total} left)${Number.isFinite(resetAt) ? ` — next slot ${new Date(resetAt).toLocaleString()}` : ""}.`,
);
err.status = 409;
err.code = "offer_user_capped";
if (Number.isFinite(resetAt)) err.resetsAtMs = resetAt;
throw err;
}
return offer;
}
async function ensureSession(token, model, proxyOptions, force = false) {
const key = sessionCacheKey(token, model);
// Lazy prune: drop stale rows so the cache never accumulates expired entries.
const cached = sessionCache.get(key);
if (cached && cached.expiresAt <= Date.now()) {
sessionCache.delete(key);
}
if (!force && cached && cached.expiresAt > Date.now()) {
return { instanceId: cached.instanceId, status: "active" };
}
if (force) {
// Drop both the cached row and any in-flight claim so the fresh POST can't
// race a stale one back into the cache.
sessionCache.delete(key);
inflight.delete(key);
return requestSession(token, model, proxyOptions);
}
if (!inflight.has(key)) {
inflight.set(key, requestSession(token, model, proxyOptions).finally(() => inflight.delete(key)));
}
return inflight.get(key);
}
// Register an agent run so the chat backend can resolve the run_id we send.
async function startRun(token, model, proxyOptions) {
const response = await fetchWithNetworkRetry(`${sessionOrigin()}${RUN_PATH}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": "codebuff-cli/0.0.138",
},
body: JSON.stringify({
action: "START",
agentId: rootAgentIdForModel(model),
ancestorRunIds: [],
}),
}, proxyOptions);
const text = await response.text().catch(() => "");
let data = {};
try { data = JSON.parse(text); } catch { data = {}; }
if (response.status === 401) {
const err = new Error("Freebuff run auth failed (401) — re-login in the dashboard");
err.status = 401;
throw err;
}
if (!response.ok) {
const err = new Error(`Freebuff run start failed: ${response.status} ${text.slice(0, 200)}`);
err.status = response.status;
throw err;
}
if (!data?.runId) {
throw new Error(`Freebuff run start returned no runId: ${text.slice(0, 200)}`);
}
return data.runId;
}
// Best-effort run completion — mirrors the CLI's finishAgentRun. Never throws.
async function finishRun(token, runId, status, proxyOptions) {
if (!runId) return;
try {
await proxyAwareFetch(`${sessionOrigin()}${RUN_PATH}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"User-Agent": "codebuff-cli/0.0.138",
},
body: JSON.stringify({ action: "FINISH", runId, status }),
signal: AbortSignal.timeout(10_000),
}, proxyOptions);
} catch {
// Best-effort only — the server sweeps stale runs.
}
}
export function resetSessionCache() {
sessionCache.clear();
inflight.clear();
offerCache.clear();
}
// Snapshot sizes of in-memory freebuff state (for the dashboard memory panel).
export function sessionStateSize() {
return {
sessions: sessionCache.size,
inflight: inflight.size,
modelLocks: modelLockCooldowns.size,
poolLimits: poolLimitCooldowns.size,
offerCaches: offerCache.size,
};
}
// Periodic sweeper: drop stale session rows + expired cooldowns so long-running
// servers never accumulate state for accounts/models no longer in use.
// Returns how many entries were removed.
export function pruneSessionState(now = Date.now()) {
let removed = 0;
for (const [key, entry] of sessionCache) {
if (entry?.expiresAt && entry.expiresAt <= now) {
sessionCache.delete(key);
removed += 1;
}
}
for (const [key, entry] of offerCache) {
if (now - entry.fetchedAt >= OFFER_CACHE_TTL_MS) {
offerCache.delete(key);
removed += 1;
}
}
for (const [key, until] of modelLockCooldowns) {
if (until <= now) {
modelLockCooldowns.delete(key);
removed += 1;
}
}
for (const [key, until] of poolLimitCooldowns) {
if (until <= now) {
poolLimitCooldowns.delete(key);
removed += 1;
}
}
return removed;
}
export class FreebuffExecutor extends BaseExecutor {
constructor() {
super("freebuff", PROVIDERS.freebuff);
}
buildUrl() {
return this.config.baseUrl;
}
// The backend's model router answers 404 "No endpoints found for {model}"
// when a tool-calling request's toolset fails its foreign_toolset gate —
// normally prevented by injecting the CLI's `end_turn` tool (see
// injectEndTurnTool). If one still slips through, surface a helpful message
// instead of a bare 404, and let the standard cooldown pace retries.
async parseError(response, bodyText) {
const text = String(bodyText || "");
if (response?.status === 404 && /No endpoints found/i.test(text)) {
return {
status: 404,
message: `Freebuff upstream rejected the request (404: "${text.trim().slice(0, 90)}"). Tool-calling requests need the CLI's end_turn tool — retry; if it persists the Codebuff backend may be having trouble.`,
resetsAtMs: Date.now() + 120_000,
};
}
return super.parseError(response, bodyText);
}
transformRequest(model, body, stream, credentials) {
// Top-level wire shape — see header comment. `run_id` and
// `freebuff_instance_id` are attached by execute() (they need the async
// run/session registration), so this only sets the static parts.
body.codebuff_metadata = {
client_id:
credentials?.providerSpecificData?.fingerprintId ||
`9router-${crypto.randomUUID()}`,
cost_mode: "free",
};
body.provider = { allow_fallbacks: false };
// Freebuff agents (base3-free-*) own reasoning: the backend applies the
// agent's reasoningOptions.effort server-side, so a client-sent
// reasoning_effort / reasoning.effort collides with that default →
// 400 "both provided with conflicting values". Mirror the CLI: send none.
delete body.reasoning_effort;
delete body.reasoning;
// Free-tier gate: first system message must open with the CLI marker.
body = injectFreebuffMarker(body);
// Foreign-toolset gate: tool-calling requests must declare `end_turn`.
return injectEndTurnTool(body);
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const token = credentials?.accessToken;
if (!token) {
throw new Error("Freebuff requires a connected Freebuff login (no access token found)");
}
// Fail fast while a known-dead (account,model) / (proxy,model) pair is in
// cooldown — no session claim, no run registration, no upstream spam.
const proxyKey = proxyKeyOf(proxyOptions);
const poolId = proxyOptions?.proxyPoolId || null;
const scope = `freebuff::${model}`;
const lockUntil = getCooldown(modelLockCooldowns, `${token}::${model}`);
if (lockUntil) {
const err = new Error(`Freebuff session locked to another model — retry after ${new Date(lockUntil).toLocaleTimeString()}`);
err.status = 409;
err.resetsAtMs = lockUntil;
throw err;
}
const poolUntil = getCooldown(poolLimitCooldowns, `${proxyKey}::${model}`);
if (poolUntil) {
const err = new Error(`Freebuff limited-mode IP rejected ${model} — retry with a full-access proxy after ${new Date(poolUntil).toLocaleTimeString()}`);
err.status = 409;
err.poolScoped = { poolId, scope, reason: "limited_ip" };
throw err;
}
let session;
try {
session = await ensureSession(token, model, proxyOptions);
} catch (error) {
const gate = sessionGateFromError(error);
if (gate) throwSessionGateError(gate, { token, model, proxyKey, poolId, log });
log?.error?.("AUTH", `Freebuff session failed: ${error.message}`);
throw error;
}
const url = this.buildUrl();
const headers = this.buildHeaders(credentials, stream);
const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry };
// Registered run whose id the backend resolves on chat. Per-request, like
// the CLI's one-run-per-prompt granularity; closure-local so concurrent
// requests never share a runId. trace_session_id mirrors the CLI's
// extraCodebuffMetadata — one per run, stable across retries.
let runId = null;
const traceSessionId = crypto.randomUUID();
const buildBody = () => {
const transformed = this.transformRequest(model, body, stream, credentials);
transformed.codebuff_metadata.run_id = runId;
transformed.codebuff_metadata.trace_session_id = traceSessionId;
if (session?.instanceId) {
transformed.codebuff_metadata.freebuff_instance_id = session.instanceId;
}
return transformed;
};
// Chat POST with connect timeout + registry 429/502/503 retry + up to 2
// retries on fetch-level network errors (per attempt the body is rebuilt
// so each retry reuses the same registered run_id).
const doChat = async () => {
let networkAttempts = 0;
const MAX_NETWORK_ATTEMPTS = 2;
for (let attempt = 0; ; attempt++) {
const transformedBody = buildBody();
const bodyStr = JSON.stringify(transformedBody);
const connectCtrl = new AbortController();
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
let response;
try {
response = await proxyAwareFetch(url, { method: "POST", headers, body: bodyStr, signal: mergedSignal }, proxyOptions);
} catch (error) {
// A caller/stream abort (AbortError) is genuine — never retry it. A
// transient socket/TLS reset (same class as the run-registration
// failure in the field) gets a couple of quick retries so a network
// blip doesn't fail the request and lock the model for 30s.
const aborted = error?.name === "AbortError";
if (aborted || networkAttempts >= MAX_NETWORK_ATTEMPTS) throw error;
networkAttempts += 1;
log?.debug?.("RETRY", `network error on ${url} (${error.message}), retry ${networkAttempts}/${MAX_NETWORK_ATTEMPTS}`);
await new Promise((resolve) => setTimeout(resolve, 750));
continue;
} finally {
clearTimeout(connectTimer);
}
const entry = resolveRetryEntry(retryConfig[response.status]);
if (entry && attempt < entry.attempts) {
log?.debug?.("RETRY", `${response.status} on ${url}, retry ${attempt + 1}/${entry.attempts} after ${entry.delayMs / 1000}s`);
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
continue;
}
return { response, transformedBody };
}
};
// The run currently in flight. Only this one is FINISH-able: after a stale
// session (428/409/410) the old run is FINISH'd "cancelled" and cleared, so
// a later failure can never double-FINISH it (the server rejects duplicate
// FINISHes for the same runId).
let activeRunId = null;
const markFinished = (status) => {
if (!activeRunId) return;
const id = activeRunId;
activeRunId = null;
finishRun(token, id, status, proxyOptions);
};
try {
try {
runId = await startRun(token, model, proxyOptions);
activeRunId = runId;
} catch (error) {
log?.error?.("AUTH", `Freebuff run start failed: ${error.message}`);
throw error;
}
let { response, transformedBody } = await doChat();
// Session gates that mean our claimed session is stale/absent:
// 428 waiting_room_required — no session row / instance id missing
// 409 session_superseded — another instance took over the session
// 409 session_model_mismatch — session bound to a different model
// 410 session_expired — the active session's expires_at passed
// model_locked / limited-tier mismatches are NOT reclaimable — the server
// keeps refusing until the session expires or the IP tier changes, so we
// set a cooldown and fail fast instead of force re-claiming in a loop.
if (SESSION_STALE_CODES.has(response.status)) {
const text = await response.text().catch(() => "");
const gate = sessionGateFromText(text);
if (gate.kind === "model_locked" || gate.kind === "limited_ip") {
markFinished("cancelled");
throwSessionGateError(gate, { token, model, proxyKey, poolId, log });
}
log?.debug?.("AUTH", `Freebuff ${response.status} session gate — re-claiming session`);
markFinished("cancelled");
try {
session = await ensureSession(token, model, proxyOptions, true);
runId = await startRun(token, model, proxyOptions);
activeRunId = runId;
} catch (error) {
const gate2 = sessionGateFromError(error);
if (gate2) throwSessionGateError(gate2, { token, model, proxyKey, poolId, log });
log?.error?.("AUTH", `Freebuff session re-claim failed: ${error.message}`);
throw error;
}
({ response, transformedBody } = await doChat());
if (SESSION_STALE_CODES.has(response.status)) {
const text2 = await response.text().catch(() => "");
const gate3 = sessionGateFromText(text2);
if (gate3.kind === "model_locked" || gate3.kind === "limited_ip") {
throwSessionGateError(gate3, { token, model, proxyKey, poolId, log });
}
const err = new Error(
`Freebuff session gate refused (${response.status}) — another freebuff instance may be holding the session. ${text2.slice(0, 160)}`,
);
err.status = response.status;
throw err;
}
}
// A successful chat means the pair is healthy again — lift any cooldowns.
if (response.ok) {
modelLockCooldowns.delete(`${token}::${model}`);
poolLimitCooldowns.delete(`${proxyKey}::${model}`);
if (poolId) clearPoolUnfit(poolId, scope);
}
// The authToken has no refresh path — when it dies, the user re-logs in.
// Drop the cached session for this token so a re-login starts clean.
if (response.status === 401) {
sessionCache.delete(sessionCacheKey(token, model));
const text = await response.text().catch(() => "");
const err = new Error(`Freebuff auth failed (401) — re-login in the dashboard. ${text.slice(0, 120)}`);
err.status = 401;
throw err;
}
// Best-effort run accounting, mirroring the CLI.
markFinished(response.ok ? "completed" : "failed");
return { response, url, headers, transformedBody };
} finally {
// Never leave the current run dangling on thrown paths (network/abort/gate).
if (activeRunId) {
finishRun(token, activeRunId, "failed", proxyOptions);
}
}
}
}
export const __test__ = {
ensureSession,
requestSession,
startRun,
resetSessionCache,
rootAgentIdForModel,
injectFreebuffMarker,
injectEndTurnTool,
fetchWithNetworkRetry,
fetchSessionOffers,
guardOfferClaim,
OFFER_GATED_MODELS,
FREEBUFF_SYSTEM_MARKER,
SESSION_STALE_CODES,
};
export default FreebuffExecutor;
+2
View File
@@ -27,6 +27,7 @@ import ZedExecutor from "./zed.js";
import WindsurfExecutor from "./windsurf.js";
import { DefaultExecutor } from "./default.js";
import { DevinCliExecutor } from "./devin-cli.js";
import { FreebuffExecutor } from "./freebuff.js";
const executors = {
antigravity: new AntigravityExecutor(),
@@ -63,6 +64,7 @@ const executors = {
zed: new ZedExecutor(),
windsurf: new WindsurfExecutor(),
"devin-cli": new DevinCliExecutor(),
freebuff: new FreebuffExecutor(),
};
const defaultCache = new Map();
+19
View File
@@ -393,6 +393,12 @@ function normalizeOpencodeReasoning(model, body) {
delete body.reasoning_effort;
}
// OpenCode free tier is limited per egress IP — a 429/403 with a limit-ish
// body means the POOL's IP is exhausted, not the account. Declare it
// pool-scoped so chatCore marks the pool unfit, retries via another pool, and
// it shows up (clearable) on the Proxy Fitness page.
const IP_LIMIT_BODY = /limit|rate|quota|exhausted|capacity|too many|retry/i;
export class OpenCodeExecutor extends BaseExecutor {
constructor() {
super("opencode", PROVIDERS.opencode);
@@ -484,4 +490,17 @@ export class OpenCodeExecutor extends BaseExecutor {
if (url.endsWith("/messages")) headers["anthropic-version"] = ANTHROPIC_API_VERSION;
return headers;
}
parseError(response, bodyText) {
const status = response?.status || 0;
const text = String(bodyText || "");
if ((status === 429 || status === 403) && IP_LIMIT_BODY.test(text)) {
return {
status,
message: text.slice(0, 300) || `OpenCode free limit (${status})`,
poolScoped: { reason: "ip-limit" },
};
}
return null; // fall through to default parsing
}
}
+100 -23
View File
@@ -31,6 +31,12 @@ import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
import { defaultClaudeToolType, shouldDefaultClaudeToolType } from "../translator/concerns/toolCall.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js";
// Pool-scoped failure retry: when an executor tags an error as belonging to a
// proxy pool (region gate, dead proxy, …), re-resolve the proxy config
// excluding that pool and retry instead of failing the whole account.
const MAX_POOL_RETRIES = 2;
/**
* Core chat handler - shared between SSE and Worker
@@ -59,7 +65,7 @@ export function stripContinuityFields(body) {
return body;
}
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
// Stable per-session color so all lines of one CLI conversation share a tag
@@ -332,16 +338,44 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
log, provider, model, reqTag
});
const proxyOptions = {
connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true,
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "",
};
// Build proxy options from the resolved provider-specific data. `strictProxy`
// is forced for freebuff so a dead/limited pool can never leak the request
// to the caller's real IP (the freebuff session tier is per-egress-IP).
const buildProxyOptions = (psd = {}) => ({
connectionProxyEnabled: psd?.connectionProxyEnabled === true,
connectionProxyUrl: psd?.connectionProxyUrl || "",
connectionNoProxy: psd?.connectionNoProxy || "",
vercelRelayUrl: psd?.vercelRelayUrl || "",
strictProxy: psd?.strictProxy === true || provider === "freebuff",
proxyPoolId: psd?.proxyPoolId || psd?.connectionProxyPoolId || null,
});
const proxyScope = `${provider}::${model}`;
let proxyOptions = buildProxyOptions(credentials?.providerSpecificData || {});
if (provider === "freebuff" && credentials?.providerSpecificData?.noFitPool === true) {
const error = new Error(`Freebuff has no healthy proxy pool for ${model}; all assigned pools are cooling down after limited-IP errors.`);
error.status = 503;
error.poolScoped = { poolId: null, scope: proxyScope, reason: "no_fit_pool" };
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(503, error.message);
}
if (
provider === "freebuff" &&
!proxyOptions.proxyPoolId &&
!proxyOptions.vercelRelayUrl &&
!(proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl)
) {
const error = new Error(`Freebuff requires a configured proxy pool for ${model}; direct egress is disabled to prevent limited-IP rate limits.`);
error.status = 503;
trackPendingRequest(model, provider, connectionId, false, true);
return createErrorResult(503, error.message);
}
if (proxyOptions.vercelRelayUrl) {
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
const poolId = proxyOptions.proxyPoolId || "none";
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`);
} else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) {
let maskedProxyUrl = proxyOptions.connectionProxyUrl;
@@ -355,7 +389,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Keep raw if URL parsing fails
}
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
const poolId = proxyOptions.proxyPoolId || "none";
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`);
}
@@ -365,23 +399,64 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`);
}
// Execute request — with pool-scoped retry: a failed pool (region gate,
// per-IP limit, dead proxy) is marked unfit and the request retried via
// another pool instead of failing the account. Covers both thrown errors
// (executor.execute) and non-ok responses declared poolScoped via
// parseError — poolId/scope are completed here from proxyOptions.
let parsedNonOk = null;
const tryNextPool = async (poolScoped, reasonMsg) => {
const failed = {
poolId: poolScoped?.poolId || proxyOptions.proxyPoolId || null,
scope: poolScoped?.scope || proxyScope,
reason: poolScoped?.reason || "pool-scoped",
};
markPoolUnfit(failed.poolId, failed.scope, undefined, failed.reason);
log?.warn?.("PROXY", `${provider.toUpperCase()} | pool ${failed.poolId || "?"} unfit for ${failed.scope} (${failed.reason}) — retry with another pool. ${reasonMsg || ""}`);
try {
const resolved = await resolveProxyConfig(credentials, [failed.poolId]);
if (resolved?.proxyPoolId) {
credentials.providerSpecificData = { ...(credentials.providerSpecificData || {}), ...resolved };
proxyOptions = buildProxyOptions(credentials.providerSpecificData);
return true;
}
} catch (resolverError) {
// A resolver failure must not mask the original pool error.
log?.warn?.("PROXY", `${provider.toUpperCase()} | pool re-resolve failed: ${resolverError.message}`);
}
return false;
};
const executeWithPoolFallback = async (attempt = 0) => {
let result;
try {
result = await executor.execute({ model, body: translatedBody, stream, credentials, providerSessionId: sessionSeed, clientTool, signal: streamController.signal, log, proxyOptions });
} catch (error) {
if (error?.poolScoped && typeof resolveProxyConfig === "function" && attempt < MAX_POOL_RETRIES) {
if (await tryNextPool(error.poolScoped, error.message)) return executeWithPoolFallback(attempt + 1);
}
throw error;
}
// Non-ok response that the executor declared pool/IP-scoped (e.g. opencode
// free per-IP limit) — parse once, retry via another pool when possible.
if (!result.response.ok) {
const parsed = await parseUpstreamError(result.response, executor);
if (parsed.poolScoped && typeof resolveProxyConfig === "function" && attempt < MAX_POOL_RETRIES) {
if (await tryNextPool(parsed.poolScoped, parsed.message)) return executeWithPoolFallback(attempt + 1);
}
parsedNonOk = parsed;
}
return result;
};
// Execute request
let providerResponse, providerUrl, providerHeaders, finalBody;
// Most executors return their registry format. Cursor AgentService is an
// exception: it is decoded by the executor into OpenAI-compatible output.
let providerResponseFormat = targetFormat;
try {
const result = await executor.execute({
model,
body: translatedBody,
stream,
credentials,
providerSessionId: sessionSeed,
clientTool,
signal: streamController.signal,
log,
proxyOptions,
});
const result = await executeWithPoolFallback();
providerResponse = result.response;
providerUrl = result.url;
providerHeaders = result.headers;
@@ -412,9 +487,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
if (log?.errorLine) {
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`);
log.errorLine(reqTag, "✗", `ERROR ${error?.status || HTTP_STATUS.BAD_GATEWAY} · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}`);
}
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
// Carry the executor's own status (429/409 quota gates) when it threw one;
// fall back to 502 for generic throws.
return createErrorResult(error?.status || HTTP_STATUS.BAD_GATEWAY, errMsg, error?.resetsAtMs || undefined);
}
// Handle 401/403 - try token refresh (skip for noAuth providers)
@@ -467,7 +544,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Provider returned error
if (!providerResponse.ok) {
trackPendingRequest(model, provider, connectionId, false, true);
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
const { statusCode, message, resetsAtMs } = parsedNonOk || await parseUpstreamError(providerResponse, executor);
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
saveRequestDetail(buildRequestDetail({
provider, model, connectionId,
@@ -140,6 +140,22 @@ function openAICompletionToResponses(responseBody, customToolNames = null) {
};
}
/**
* Unwrap gateway envelopes around an OpenAI Chat Completions body.
* Some OpenAI-compatible gateways wrap the body in a `data` envelope
* (Cline: {data, success}) — without this, choices/usage don't resolve at
* top level downstream and surface as "no completion choices".
* Generic guard, no provider hardcode: only fires when the OpenAI body is
* nested under `data`. Callers decide when to apply it — the handler scopes
* it to providers opting in via transport.quirks.clineEnvelope.
*/
export function unwrapDataEnvelope(responseBody) {
if (responseBody && !responseBody.choices && responseBody.data?.choices) {
return responseBody.data;
}
return responseBody;
}
/**
* Translate non-streaming response body from provider format → OpenAI format.
*/
@@ -312,6 +328,8 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
responseBody = unwrapClineEnvelope(responseBody, provider);
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
// Unwrap AFTER logging (raw envelope stays in the log for forensics) but
// BEFORE usage extraction/translation so choices/usage resolve downstream.
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
+6
View File
@@ -307,6 +307,12 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
{ pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
// ── Cline free tier: Upstage Solar Pro + LongCat (agentic coding models;
// windows unverified — 200K/32K conservative, same as laguna:free).
// NOTE: placed before the o-series catch-alls — "solar-pro4" contains "o4".
{ pattern: "*solar-pro*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
{ pattern: "*longcat*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
// ── OpenAI o-series (reasoning, vision) ──────────────────────────
{ pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
{ pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
+15 -1
View File
@@ -14,8 +14,9 @@ export default {
},
},
category: "oauth",
authModes: ["oauth"],
authModes: ["oauth", "apikey"],
hasOAuth: true,
authHint: "API key dari app.cline.bot → Settings > API Keys, atau login OAuth via browser.",
transport: {
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
headers: {
@@ -30,6 +31,9 @@ export default {
combined: true,
header: "Authorization",
scheme: "bearer",
// Hook owns Authorization: workos:-prefixed OAuth vs plain API key
// (a merged token can't express both) — see applyAuth.
preserveHookAuth: true,
hooks: [
"clineHeaders",
],
@@ -44,6 +48,16 @@ export default {
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "kwaipilot/kat-coder-pro", name: "KAT Coder Pro" },
// Free tier (verified live via GET /api/v1/ai/cline/recommended-models):
// billed $0 on usage, limited quota separate from ClinePass. The
// cline-free/* aliases require Cline product headers (see shared/clineAuth.js)
// or upstream 403s with "only available via Cline product surfaces".
{ id: "cline-free/muse-spark-1.3-contributor", name: "Muse Spark 1.3 (Free)" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" },
{ id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash (Free)" },
{ id: "cline-free/solar-pro4", name: "Solar Pro 4 (Free)" },
{ id: "cline-free/longcat-2.0", name: "LongCat 2.0 (Free)" },
{ id: "poolside/laguna-s-2.1:free", name: "Laguna S 2.1 (Free)" },
],
oauth: {
appBaseUrl: "https://app.cline.bot",
+3
View File
@@ -31,6 +31,9 @@ export default {
combined: true,
header: "Authorization",
scheme: "bearer",
// Hook owns Authorization: workos:-prefixed OAuth vs plain API key
// (a merged token can't express both) — see applyAuth.
preserveHookAuth: true,
hooks: [
"clineHeaders",
],
File diff suppressed because one or more lines are too long
+126 -131
View File
@@ -1,130 +1,127 @@
// Auto-generated: static imports for all registry entries
import p0 from "./alicode-intl.js";
import p1 from "./alicode.js";
import p2 from "./anthropic.js";
import p3 from "./antigravity.js";
import p4 from "./assemblyai.js";
import p5 from "./aws-polly.js";
import p6 from "./azure.js";
import p7 from "./black-forest-labs.js";
import p8 from "./blackbox.js";
import p9 from "./brave-search.js";
import p10 from "./byteplus.js";
import p11 from "./cartesia.js";
import p12 from "./cerebras.js";
import p13 from "./chutes.js";
import p14 from "./claude.js";
import p15 from "./cline.js";
import p16 from "./clinepass.js";
import p17 from "./cloudflare-ai.js";
import p18 from "./codebuddy-cn.js";
import p19 from "./codex.js";
import p20 from "./cohere.js";
import p21 from "./comfyui.js";
import p22 from "./commandcode.js";
import p23 from "./coqui.js";
import p24 from "./cursor.js";
import p25 from "./deepgram.js";
import p26 from "./deepseek.js";
import p27 from "./edge-tts.js";
import p28 from "./elevenlabs.js";
import p29 from "./exa.js";
import p30 from "./fal-ai.js";
import p31 from "./featherless.js";
import p32 from "./firecrawl.js";
import p33 from "./fireworks.js";
import p34 from "./gemini-cli.js";
import p35 from "./gemini.js";
import p36 from "./github.js";
import p37 from "./gitlab.js";
import p38 from "./glm-cn.js";
import p39 from "./glm.js";
import p40 from "./google-pse.js";
import p41 from "./google-tts.js";
import p42 from "./grok-cli.js";
import p43 from "./grok-web.js";
import p44 from "./groq.js";
import p45 from "./huggingface.js";
import p46 from "./hyperbolic.js";
import p47 from "./iflow.js";
import p48 from "./inworld.js";
import p49 from "./jina-ai.js";
import p50 from "./jina-reader.js";
import p51 from "./kilocode.js";
import p52 from "./kimchi.js";
import p53 from "./kimi.js";
import p54 from "./kiro.js";
import p55 from "./linkup.js";
import p56 from "./local-device.js";
import p57 from "./mimo-free.js";
import p58 from "./minimax-cn.js";
import p59 from "./minimax.js";
import p60 from "./mistral.js";
import p61 from "./mmf.js";
import p62 from "./nanobanana.js";
import p63 from "./nebius.js";
import p64 from "./nvidia.js";
import p65 from "./ollama-local.js";
import p66 from "./ollama.js";
import p123 from "./ollama-search.js";
import p67 from "./openai.js";
import p68 from "./opencode-go.js";
import p68z from "./opencode-zen.js";
import p69 from "./opencode.js";
import p70 from "./openrouter.js";
import p71 from "./perplexity-web.js";
import p72 from "./perplexity.js";
import p73 from "./perplexity-agent.js";
import p74 from "./playht.js";
import p75 from "./qoder.js";
import p124 from "./qoder-cn.js";
import p77 from "./recraft.js";
import p78 from "./runwayml.js";
import p79 from "./sdwebui.js";
import p80 from "./searchapi.js";
import p81 from "./searxng.js";
import p82 from "./serper.js";
import p83 from "./siliconflow.js";
import p84 from "./stability-ai.js";
import p85 from "./tavily.js";
import p86 from "./together.js";
import p87 from "./topaz.js";
import p88 from "./tortoise.js";
import p89 from "./venice.js";
import p90 from "./vercel-ai-gateway.js";
import p91 from "./vertex-partner.js";
import p92 from "./vertex.js";
import p93 from "./volcengine-ark.js";
import p94 from "./voyage-ai.js";
import p95 from "./xai.js";
import p96 from "./xiaomi-mimo.js";
import p97 from "./xiaomi-tokenplan.js";
import p98 from "./youcom.js";
import p99 from "./alims-intl.js";
import p100 from "./codebuddy-intl.js";
// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk).
// Re-enable by uncommenting both the import and the array entry below.
// import p102 from "./trae.js";
import p103 from "./zed.js";
import p105 from "./api-airforce.js";
import p106 from "./baidu.js";
import p107 from "./bazaarlink.js";
import p108 from "./bluesminds.js";
import p109 from "./kilo-gateway.js";
import p110 from "./llm7.js";
import p111 from "./sambanova.js";
import p112 from "./tencent.js";
import p113 from "./morph.js";
// import p114 from "./devin-cli.js";
// import p104 from "./windsurf.js";
import p115 from "./poolside.js";
import p116 from "./tokenrouter.js";
import p117 from "./selfhosted-stt.js";
import p118 from "./selfhosted-tts.js";
import p119 from "./selfhosted-embedding.js";
import p120 from "./fish-audio.js";
import p121 from "./alitp-intl.js";
import p122 from "./xquik.js";
import p2 from "./alims-intl.js";
import p3 from "./alitp-intl.js";
import p4 from "./anthropic.js";
import p5 from "./antigravity.js";
import p6 from "./api-airforce.js";
import p7 from "./assemblyai.js";
import p8 from "./aws-polly.js";
import p9 from "./azure.js";
import p10 from "./baidu.js";
import p11 from "./bazaarlink.js";
import p12 from "./black-forest-labs.js";
import p13 from "./blackbox.js";
import p14 from "./bluesminds.js";
import p15 from "./brave-search.js";
import p16 from "./byteplus.js";
import p17 from "./cartesia.js";
import p18 from "./cerebras.js";
import p19 from "./chutes.js";
import p20 from "./claude.js";
import p21 from "./cline.js";
import p22 from "./clinepass.js";
import p23 from "./cloudflare-ai.js";
import p24 from "./codebuddy-cn.js";
import p25 from "./codebuddy-intl.js";
import p26 from "./codex.js";
import p27 from "./cohere.js";
import p28 from "./comfyui.js";
import p29 from "./commandcode.js";
import p30 from "./coqui.js";
import p31 from "./cursor.js";
import p32 from "./deepgram.js";
import p33 from "./deepseek.js";
import p34 from "./edge-tts.js";
import p35 from "./elevenlabs.js";
import p36 from "./exa.js";
import p37 from "./fal-ai.js";
import p38 from "./featherless.js";
import p39 from "./firecrawl.js";
import p40 from "./fireworks.js";
import p41 from "./fish-audio.js";
import p42 from "./freebuff.js";
import p43 from "./gemini-cli.js";
import p44 from "./gemini.js";
import p45 from "./github.js";
import p46 from "./gitlab.js";
import p47 from "./glm-cn.js";
import p48 from "./glm.js";
import p49 from "./google-pse.js";
import p50 from "./google-tts.js";
import p51 from "./grok-cli.js";
import p52 from "./grok-web.js";
import p53 from "./groq.js";
import p54 from "./huggingface.js";
import p55 from "./hyperbolic.js";
import p56 from "./iflow.js";
import p57 from "./inworld.js";
import p58 from "./jina-ai.js";
import p59 from "./jina-reader.js";
import p60 from "./kilo-gateway.js";
import p61 from "./kilocode.js";
import p62 from "./kimchi.js";
import p63 from "./kimi.js";
import p64 from "./kiro.js";
import p65 from "./linkup.js";
import p66 from "./llm7.js";
import p67 from "./local-device.js";
import p68 from "./mimo-free.js";
import p69 from "./minimax-cn.js";
import p70 from "./minimax.js";
import p71 from "./mistral.js";
import p72 from "./mmf.js";
import p73 from "./morph.js";
import p74 from "./nanobanana.js";
import p75 from "./nebius.js";
import p76 from "./nvidia.js";
import p77 from "./ollama-local.js";
import p78 from "./ollama-search.js";
import p79 from "./ollama.js";
import p80 from "./openai.js";
import p81 from "./opencode-go.js";
import p82 from "./opencode-zen.js";
import p83 from "./opencode.js";
import p84 from "./openrouter.js";
import p85 from "./perplexity-agent.js";
import p86 from "./perplexity-web.js";
import p87 from "./perplexity.js";
import p88 from "./playht.js";
import p89 from "./poolside.js";
import p90 from "./qoder-cn.js";
import p91 from "./qoder.js";
import p92 from "./recraft.js";
import p93 from "./runwayml.js";
import p94 from "./sambanova.js";
import p95 from "./sdwebui.js";
import p96 from "./searchapi.js";
import p97 from "./searxng.js";
import p98 from "./selfhosted-embedding.js";
import p99 from "./selfhosted-stt.js";
import p100 from "./selfhosted-tts.js";
import p101 from "./serper.js";
import p102 from "./siliconflow.js";
import p103 from "./stability-ai.js";
import p104 from "./tavily.js";
import p105 from "./tencent.js";
import p106 from "./together.js";
import p107 from "./tokenrouter.js";
import p108 from "./topaz.js";
import p109 from "./tortoise.js";
import p110 from "./venice.js";
import p111 from "./vercel-ai-gateway.js";
import p112 from "./vertex-partner.js";
import p113 from "./vertex.js";
import p114 from "./volcengine-ark.js";
import p115 from "./voyage-ai.js";
import p116 from "./xai.js";
import p117 from "./xiaomi-mimo.js";
import p118 from "./xiaomi-tokenplan.js";
import p119 from "./xquik.js";
import p120 from "./youcom.js";
import p121 from "./zed.js";
export default [
p0,
p1,
@@ -193,11 +190,8 @@ export default [
p64,
p65,
p66,
p123,
p124,
p67,
p68,
p68z,
p69,
p70,
p71,
@@ -205,6 +199,7 @@ export default [
p73,
p74,
p75,
p76,
p77,
p78,
p79,
@@ -229,8 +224,10 @@ export default [
p98,
p99,
p100,
// p102, // trae — hidden, no tool calling
p101,
p102,
p103,
p104,
p105,
p106,
p107,
@@ -240,8 +237,7 @@ export default [
p111,
p112,
p113,
// p114, // devin-cli — hidden, spawns local agent with shell/fs access
// p104, // windsurf — hidden, no tool calling
p114,
p115,
p116,
p117,
@@ -249,5 +245,4 @@ export default [
p119,
p120,
p121,
p122,
];
+3 -9
View File
@@ -5,17 +5,11 @@ const FETCH_TIMEOUT_MS = 5000;
/**
* Build request headers for the ClinePass /models endpoint (Cline's upstream API).
* - API keys are sent as plain Bearer tokens.
* - OAuth access tokens must carry the WorkOS `workos:` prefix (handled by buildClineHeaders).
* Auth shape lives in shared/clineAuth: API keys ride plain Bearer, OAuth
* access tokens carry the WorkOS `workos:` prefix.
*/
function buildModelListHeaders(token, isApiKey) {
if (isApiKey) {
return {
Accept: "application/json",
Authorization: `Bearer ${token}`,
};
}
return buildClineHeaders(token, { Accept: "application/json" });
return buildClineHeaders(token, { Accept: "application/json" }, { isApiKey });
}
/**
+176
View File
@@ -0,0 +1,176 @@
// Pool egress geo — engine layer, shared by the dashboard UI (Proxy Fitness /
// Proxy Pools egress column) and any provider region policy that wants to
// pre-mark pools unfit by egress region.
//
// The probe transport is provider-agnostic: it fetches a geo service THROUGH
// the pool itself (relay headers for vercel/cloudflare/deno, proxy URL for
// socks/http), so it works for every pool type and every provider.
//
// A chain of free endpoints is tried in order so a rate-limited or broken
// provider drops to the next; ipinfo (the most quota-bound) is last.
//
// State lives on globalThis (same reason as proxyPoolFitness): the background
// probe and the /api/proxy-pools reader must share ONE cache across Next dev
// bundles.
const GEO_STATE_KEY = "__9routerPoolGeo__";
const geoCache = (globalThis[GEO_STATE_KEY] ??= new Map()); // poolId -> { ip, country, ..., ts, ipHistory }
export const POOL_GEO_TTL_MS = 60 * 60 * 1000;
// How many past egress IPs to remember for flapping detection.
export const POOL_GEO_IP_HISTORY_MAX = 8;
// Test helper: drop all cached geo (module state is globalThis-backed).
export function resetPoolGeo() {
geoCache.clear();
}
// Attach stability classification: >=2 distinct egress IPs observed = flapping
// (typical for serverless relays — Vercel/Cloudflare egress varies per colo).
function withStability(entry) {
const ips = new Set([entry?.ip, ...(entry?.ipHistory || []).map((h) => h.ip)]);
ips.delete("");
const ipCount = ips.size;
return { ...entry, ipCount, isUnstable: ipCount >= 2 };
}
export function getPoolGeo(poolId) {
const entry = geoCache.get(poolId);
if (!entry) return null;
if (entry.ts + POOL_GEO_TTL_MS < Date.now()) {
geoCache.delete(poolId);
return null;
}
return withStability(entry);
}
export function setPoolGeo(poolId, geo) {
if (!poolId || !geo?.ip) return;
const prev = geoCache.get(poolId);
const ipHistory = prev?.ipHistory ? [...prev.ipHistory] : [];
if (prev?.ip && prev.ip !== geo.ip) {
// Record the IP we are leaving — the history tracks past egress IPs.
ipHistory.push({ ip: prev.ip, ts: Date.now() });
if (ipHistory.length > POOL_GEO_IP_HISTORY_MAX) ipHistory.shift();
}
geoCache.set(poolId, { ...geo, ts: Date.now(), ipHistory });
}
export function poolGeoSnapshot(now = Date.now()) {
const out = {};
for (const [poolId, entry] of geoCache) {
if (entry.ts + POOL_GEO_TTL_MS <= now) {
geoCache.delete(poolId);
continue;
}
out[poolId] = withStability(entry);
}
return out;
}
// Sweep geo entries past their TTL (ipHistory rides along with the entry).
// Returns how many entries were removed.
export function pruneStaleGeo(now = Date.now()) {
let removed = 0;
for (const [poolId, entry] of geoCache) {
if (entry.ts + POOL_GEO_TTL_MS <= now) {
geoCache.delete(poolId);
removed += 1;
}
}
return removed;
}
// Normalize a single provider's geo payload to { ip, country, region, city, org }.
const GEO_PARSE = {
"ipwho.is": (d) => ({ ip: d?.ip, country: d?.country, region: d?.region, city: d?.city, org: d?.org || d?.connection?.org }),
"ip-api": (d) => ({ ip: d?.query, country: d?.country, region: d?.regionName, city: d?.city, org: d?.org }),
"ipapi.co": (d) => ({ ip: d?.ip, country: d?.country_name, region: d?.region, city: d?.city, org: d?.org }),
ipinfo: (d) => ({ ip: d?.ip, country: d?.country, region: d?.region, city: d?.city, org: d?.org }),
};
// Tried in order — rate-friendly first, quota-bound ipinfo last.
const GEO_PROBES = [
{ name: "ipwho.is", url: "https://ipwho.is/" },
{ name: "ip-api", url: "https://ip-api.com/json/?fields=status,message,query,country,regionName,city,org" },
{ name: "ipapi.co", url: "https://ipapi.co/json/" },
{ name: "ipinfo", url: "https://ipinfo.io/json" },
];
// Custom single endpoint via env (replaces the chain). Keys mapped generically.
function customGeoParse(d) {
if (!d || typeof d !== "object") return null;
const pick = (...keys) => keys.map((k) => d[k]).find((v) => v != null && v !== "");
return {
ip: pick("query", "ip"),
country: pick("country", "country_name", "countryName"),
region: pick("regionName", "region", "state", "regionCode"),
city: pick("city"),
org: pick("org", "organization", "isp", "orgName", "connection", "asn"),
};
}
function probeSource({ proxyAwareFetch, url, proxyOptions, timeoutMs, name }) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(new Error("geo probe timeout")), timeoutMs);
return (async () => {
try {
const res = await proxyAwareFetch(url, { signal: ctrl.signal }, proxyOptions);
if (!res.ok) {
const txt = await res.text().catch(() => "");
const error = res.status === 429 || res.status === 403 ? "rate-limit"
: res.status >= 500 ? "server"
: "network";
return { ok: false, error, detail: `${res.status} ${txt.slice(0, 80)}` };
}
const data = await res.json().catch(() => null);
const parsed = name ? GEO_PARSE[name]?.(data) : customGeoParse(data);
if (!parsed?.ip) return { ok: false, error: "no-ip", detail: `${name || "custom"}` };
return {
ok: true,
geo: {
ip: String(parsed.ip || "").trim(),
country: String(parsed.country || "").trim(),
region: String(parsed.region || "").trim(),
city: String(parsed.city || "").trim(),
org: String(parsed.org || "").trim(),
isDatacenter: /(cloudflare|vercel|amazon|aws|google|microsoft|azure|digitalocean|hetzner|ovh|contabo|leaseweb)/i.test(
String(parsed.org || ""),
),
},
};
} catch (error) {
const timedOut = ctrl.signal?.aborted && error?.name === "AbortError";
return { ok: false, error: timedOut ? "timeout" : "network", detail: `${error?.name}: ${error?.message}` };
} finally {
clearTimeout(timer);
}
})();
}
// Probe the egress geo of one pool through a chain of geo providers. Fail-open:
// returns { ok:true, geo } on success, { ok:false, error } otherwise with
// `error` one of "rate-limit" | "server" | "no-ip" | "network" | "timeout".
// `pool` shape: { proxyUrl, type }. 15s timeout per endpoint by default.
export async function probePoolGeo(pool, timeoutMs = 15000) {
const proxyUrl = pool?.proxyUrl;
if (!proxyUrl) return { ok: false, error: "network", detail: "no proxy url" };
const { proxyAwareFetch } = await import("../utils/proxyFetch.js");
const isRelay = ["vercel", "cloudflare", "deno"].includes(pool?.type);
const proxyOptions = isRelay
? { vercelRelayUrl: proxyUrl }
: { connectionProxyEnabled: true, connectionProxyUrl: proxyUrl };
const custom = process.env.GEO_PROBE_URL ? [{ name: null, url: process.env.GEO_PROBE_URL }] : [];
const probes = custom.length ? custom : GEO_PROBES;
let last = { ok: false, error: "network", detail: "no provider responded" };
for (const p of probes) {
const res = await probeSource({ proxyAwareFetch, url: p.url, proxyOptions, timeoutMs, name: p.name });
if (res.ok) return res;
// Prefer the most specific failure: rate-limit/server over generic network.
if (res.error === "rate-limit" || res.error === "server") last = res;
else if (last.error === "network") last = res;
}
return last;
}
+167
View File
@@ -0,0 +1,167 @@
// Pool fitness registry — shared, in-memory, engine layer.
//
// Rotation strategies can opt into region/provider-aware pool selection: an
// executor that learns a pool's egress is unfit for a provider/model (e.g.
// Freebuff limited-mode IP) marks it here, and the pool picker skips it for
// that scope until the cooldown expires.
//
// Scope format: `provider::model` (e.g. "freebuff::openai/gpt-5.6-luna").
// All functions are fail-open: unknown pool/scope ⇒ fit.
// Scope format: `provider::model` (e.g. "freebuff::openai/gpt-5.6-luna").
// All functions are fail-open: unknown pool/scope ⇒ fit.
//
// State lives on globalThis so Next dev (Turbopack) never splits one Map into
// several per-bundle copies — the executor/chatCore marks and the
// /api/proxy-pools/fitness reader must share the SAME registry.
const FITNESS_STATE_KEY = "__9routerPoolFitness__";
const fitness = (globalThis[FITNESS_STATE_KEY] ??= new Map()); // poolId -> Map<scope, { until, reason }>
let persistTimer = null;
let hydratePromise = null;
export const POOL_UNFIT_MS = 5 * 60 * 1000;
function schedulePersist() {
if (persistTimer) return;
persistTimer = setTimeout(async () => {
persistTimer = null;
try {
const { updateSettings } = await import("@/lib/db/repos/settingsRepo.js");
await updateSettings({ proxyPoolFitness: poolFitnessSnapshot() });
} catch {
// Fitness is advisory; persistence failures must not block requests.
}
}, 25);
if (persistTimer.unref) persistTimer.unref();
}
export function hydratePoolFitness(snapshot = {}) {
for (const [poolId, byScope] of Object.entries(snapshot || {})) {
const entries = Object.entries(byScope || {}).filter(([, entry]) => Number(entry?.until) > Date.now());
if (entries.length) fitness.set(poolId, new Map(entries));
}
}
export async function ensurePoolFitnessHydrated() {
if (!hydratePromise) {
hydratePromise = import("@/lib/db/repos/settingsRepo.js")
.then(({ getSettings }) => getSettings())
.then((settings) => hydratePoolFitness(settings.proxyPoolFitness || {}))
.catch(() => {})
.then(() => undefined);
}
return hydratePromise;
}
export function markPoolUnfit(poolId, scope, until = Date.now() + POOL_UNFIT_MS, reason = "") {
if (!poolId || !scope) return;
const byScope = fitness.get(poolId) || new Map();
byScope.set(scope, { until, reason });
fitness.set(poolId, byScope);
schedulePersist();
}
export function clearPoolUnfit(poolId, scope) {
const byScope = fitness.get(poolId);
if (!byScope) return;
byScope.delete(scope);
if (byScope.size === 0) fitness.delete(poolId);
schedulePersist();
}
// "provider::model" -> "provider::*" (null when the scope has no provider part)
function providerWildcardScope(scope) {
const sep = String(scope || "").indexOf("::");
if (sep < 0) return null;
return `${scope.slice(0, sep)}::*`;
}
export function isPoolFit(poolId, scope, now = Date.now()) {
if (!poolId) return true;
const byScope = fitness.get(poolId);
if (!byScope) return true;
// A provider-wide mark ("provider::*", e.g. manual blocks) also covers any
// model lookup for that provider.
const candidates = [scope, providerWildcardScope(scope)];
for (const key of candidates) {
if (!key) continue;
const entry = byScope.get(key);
if (!entry) continue;
if (entry.until <= now) {
byScope.delete(key);
if (byScope.size === 0) fitness.delete(poolId);
continue;
}
return false;
}
return true;
}
// Keep only pool ids that are not in cooldown for the scope.
export function fitPoolIds(poolIds, scope, now = Date.now()) {
return (poolIds || []).filter((id) => isPoolFit(id, scope, now));
}
// Clear every mark — or only scopes belonging to one provider (`provider::*`).
export function clearAllPoolUnfit(provider = null) {
if (provider) {
const prefix = `${provider}::`;
for (const [poolId, byScope] of fitness) {
for (const scope of [...byScope.keys()]) {
if (scope.startsWith(prefix)) byScope.delete(scope);
}
if (byScope.size === 0) fitness.delete(poolId);
}
schedulePersist();
return;
}
fitness.clear();
schedulePersist();
}
// Snapshot of live (non-expired) marks — expired entries are pruned here so
// consumers never see stale data and memory stays bounded.
// Test helper: drop all marks (module state is globalThis-backed).
export function resetPoolFitness() {
fitness.clear();
hydratePromise = Promise.resolve();
schedulePersist();
}
// Sweep all expired marks. Returns how many scope entries were removed.
export function pruneExpired(now = Date.now()) {
let removed = 0;
for (const [poolId, byScope] of fitness) {
for (const [scope, entry] of byScope) {
if (entry.until <= now) {
byScope.delete(scope);
removed += 1;
}
}
if (byScope.size === 0) fitness.delete(poolId);
}
if (removed) schedulePersist();
return removed;
}
export function poolFitnessSnapshot(now = Date.now()) {
const out = {};
for (const [poolId, byScope] of fitness) {
let pruned = false;
for (const [scope, entry] of byScope) {
if (entry.until <= now) {
byScope.delete(scope);
pruned = true;
}
}
if (byScope.size === 0) {
fitness.delete(poolId);
continue;
}
if (pruned || byScope.size > 0) {
out[poolId] = Object.fromEntries(byScope);
}
}
return out;
}
+2
View File
@@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn
import { getGrokCliUsage } from "./usage/grok-cli.js";
import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { getFreebuffUsage } from "./usage/freebuff.js";
import { getOpenCodeGoUsage } from "./usage/opencode-go.js";
import { getOpenCodeZenUsage } from "./usage/opencode-zen.js";
import { getGroqUsage } from "./usage/groq.js";
@@ -58,6 +59,7 @@ const USAGE_HANDLERS = {
"opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions),
"opencode-zen": (c) => getOpenCodeZenUsage(c.apiKey, c.proxyOptions),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
freebuff: (c) => getFreebuffUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions),
zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
"xiaomi-mimo": (c) => getXiaomiMimoUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
+201
View File
@@ -0,0 +1,201 @@
/**
* Freebuff usage handler
*
* Freebuff has no separate billing/quota API — the daily free/premium session
* quota lives on the session endpoint itself. Reading it MUST use
* GET /api/v1/freebuff/session (the CLI's status poll): POST would CLAIM a
* session and burn 1.0 unit of the daily quota, which a quota tracker must
* never do.
*
* The GET response carries the shared session quota as `rateLimitsByModel`,
* keyed by model id, on the pre-join (`none`), `active`, and `ended` states:
* { limit, recentCount, resetAt, period: 'pacific_day'|'pacific_week',
* resetTimeZone, entitlementBreakdown? }
* `recentCount` is fractional — a long agent run can consume 1.3 units — and
* includes the active session's own 1.0-unit reservation. `limit` can be
* raised by referral/streak rewards (entitlementBreakdown.base + referral +
* streak).
*
* Freebucks-metered accounts (2026-09-02+) get NO pool rows: the server sends
* a `freebucks` block instead — { balance, daily:{limit,spent,remaining,resetAt},
* wallet:{balance,...}, monthly?:{limitUsd,spentUsd,remainingUsd,resetAt},
* planId, prices:{model→Freebucks/hr}, priceNotices?, priceChanges? }. The
* daily pool replaces rateLimitsByModel, so the tracker folds it in by
* replicating the pool snapshot under every priced model id, exactly as the
* official picker does.
*
* Pricing is SERVER-AUTHORITATIVE and nothing here hardcodes a number: each
* row carries the live `prices[model]` (plus its `priceNotices` tagline), and
* `applyFreebucksPriceChanges` folds the server's announced `priceChanges`
* schedule into the map — a promo (e.g. Solar Pro 4's Labor Day 0-Freebucks
* offer) expires and reverts on the server's timeline with no client release,
* mirroring upstream common/src/util/freebuff-price-changes.ts. A compact
* account summary (`freebucks`) rides the response for the card header.
*/
import REGISTRY from "../../providers/registry/index.js";
import { U, fetchWithTimeout } from "./shared.js";
// Friendly labels from the registry model list (mirrors the CLI picker).
const freebuffRegistry = REGISTRY.find((r) => r.id === "freebuff") || {};
const MODEL_LABELS = Object.fromEntries(
(freebuffRegistry.models || []).map((m) => [m.id, m.name]),
);
// Fold the server's announced price schedule into the live price map —
// mirrors upstream applyFreebucksPriceChanges: due changes override the price
// AND its picker tagline, and drop off the pending list.
function applyFreebucksPriceChanges(info) {
const now = Date.now();
const due = (info?.priceChanges || []).filter((c) => Date.parse(c.at) <= now);
if (due.length === 0) return info;
const prices = { ...(info.prices || {}) };
const priceNotices = { ...(info.priceNotices || {}) };
for (const change of due.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))) {
if (prices[change.modelId] === undefined) continue;
prices[change.modelId] = change.price;
priceNotices[change.modelId] = change.tagline;
}
return {
...info,
prices,
priceNotices,
priceChanges: (info.priceChanges || []).filter((c) => Date.parse(c.at) > now),
};
}
function sessionUrl() {
return U("freebuff").url;
}
export async function getFreebuffUsage(accessToken, providerSpecificData, proxyOptions = null) {
if (!accessToken) {
return { message: "Freebuff credential not available — connect a Freebuff login first." };
}
try {
const response = await fetchWithTimeout(
sessionUrl(),
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"User-Agent": "codebuff-cli/0.0.138",
Accept: "application/json",
},
},
15000,
proxyOptions,
);
if (response.status === 401) {
return { message: "Freebuff credential invalid or expired — re-login in the dashboard." };
}
if (response.status === 403) {
// A 403 from the session endpoint is usually a server-side gate status
// (country_blocked / banned), not a credential problem — telling the
// user to re-login would be misleading (mirrors the CLI's
// callFreebuffSession 403 branch).
const body = await response.json().catch(() => ({}));
if (body?.status === "country_blocked") {
return { message: "Freebuff is not available in your region." };
}
if (body?.status === "banned") {
return { message: "Your Freebuff account has been banned." };
}
return {
message: `Freebuff quota access denied (403)${body?.message ? `: ${body.message}` : ""}.`,
};
}
// 404 = no session row at all → pre-join state, no quota to report.
if (response.status === 404) {
return { plan: "Freebuff", message: "Freebuff connected. No session quota to report right now." };
}
if (!response.ok) {
return { message: `Freebuff quota API error (${response.status}).` };
}
const data = await response.json().catch(() => ({}));
const rateLimits = { ...(data.rateLimitsByModel || {}) };
// An active session carries its own `rateLimit` row — fold it in when the
// shared map omits the model (older servers).
if (data.status === "active" && data.rateLimit && !rateLimits[data.model]) {
rateLimits[data.model] = data.rateLimit;
}
const quotas = {};
for (const [model, rl] of Object.entries(rateLimits)) {
if (!rl || typeof rl !== "object") continue;
const used = Number(rl.recentCount);
const total = Number(rl.limit);
quotas[model] = {
used: Number.isFinite(used) ? used : 0,
total: Number.isFinite(total) ? total : 0,
resetAt: rl.resetAt || null,
unlimited: false,
// Daily/weekly Pacific session allowance replenishes at resetAt — the
// UI must say "Resets in", not "Expires in".
recurring: true,
...(MODEL_LABELS[model] ? { displayName: MODEL_LABELS[model] } : {}),
};
}
// Freebucks meter: the daily Freebucks pool is the quota for metered
// accounts, replicated under each priced model like the picker's rings.
// Prices come off the wire (prices + priceChanges folded in); each row
// carries its own price/tagline, and a compact summary rides the response
// for the account header in the UI.
let freebucksSummary = null;
const rawFreebucks = data.freebucks;
if (rawFreebucks && rawFreebucks.daily && typeof rawFreebucks.daily === "object") {
const freebucks = applyFreebucksPriceChanges(rawFreebucks);
const spent = Number(freebucks.daily.spent);
const limit = Number(freebucks.daily.limit);
for (const [model, price] of Object.entries(freebucks.prices || {})) {
quotas[model] = {
used: Number.isFinite(spent) ? spent : 0,
total: Number.isFinite(limit) ? limit : 0,
resetAt: freebucks.daily.resetAt || null,
unlimited: false,
recurring: true,
price: Number.isFinite(Number(price)) ? Number(price) : undefined,
...(freebucks.priceNotices?.[model] ? { priceNote: freebucks.priceNotices[model] } : {}),
...(MODEL_LABELS[model] ? { displayName: MODEL_LABELS[model] } : {}),
};
}
freebucksSummary = {
balance: Number.isFinite(Number(freebucks.balance)) ? Number(freebucks.balance) : null,
daily: {
limit: Number.isFinite(limit) ? limit : 0,
spent: Number.isFinite(spent) ? spent : 0,
remaining: Number.isFinite(Number(freebucks.daily.remaining)) ? Number(freebucks.daily.remaining) : 0,
resetAt: freebucks.daily.resetAt || null,
},
wallet: {
balance: Number.isFinite(Number(freebucks.wallet?.balance)) ? Number(freebucks.wallet.balance) : 0,
},
...(freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd))
? {
monthly: {
remainingUsd: Number(freebucks.monthly.remainingUsd),
limitUsd: Number.isFinite(Number(freebucks.monthly.limitUsd)) ? Number(freebucks.monthly.limitUsd) : null,
resetAt: freebucks.monthly.resetAt || null,
},
}
: {}),
};
}
const plan = data.accessTier === "limited" ? "Freebuff (Limited)" : "Freebuff";
if (Object.keys(quotas).length === 0) {
return { plan, message: "Freebuff connected. No session quota to report right now." };
}
const out = { plan, quotas };
if (freebucksSummary) out.freebucks = freebucksSummary;
return out;
} catch (error) {
return { message: `Freebuff usage error: ${error.message}` };
}
}
export default getFreebuffUsage;
+24 -11
View File
@@ -1,6 +1,10 @@
import pkg from "../../package.json" with { type: "json" };
const APP_VERSION = pkg.version || "0.0.0";
// Cline CLI identity (mirrors apps/cli + sdk/packages/llms request-headers.ts
// in cline/cline). Upstream gates the cline-free/* model aliases to Cline
// product surfaces + recent client versions — requests sent as
// X-CLIENT-TYPE 9router are 403'd with "only available via Cline product
// surfaces". Verified live 2026-09-11: cline-cli/3.0.61 passes the gate.
const CLINE_CLIENT_TYPE = "cline-cli";
const CLINE_CLIENT_VERSION = "3.0.61";
export function getClineAccessToken(token) {
if (typeof token !== "string") return "";
@@ -21,17 +25,26 @@ export function getClineAuthorizationHeader(token) {
return accessToken ? `Bearer ${accessToken}` : "";
}
export function buildClineHeaders(token, extraHeaders = {}) {
const authorization = getClineAuthorizationHeader(token);
export function buildClineHeaders(token, extraHeaders = {}, opts = {}) {
// API keys ride plain Bearer; OAuth access tokens must carry the WorkOS
// `workos:` prefix so the backend routes verification to WorkOS
// (cline/cline: "Prefixed with 'workos:'..."). Verified live 2026-09-11:
// plain sk_* works, workos:sk_* → 401.
const trimmed = typeof token === "string" ? token.trim() : "";
const authorization = !trimmed
? ""
: opts.isApiKey
? `Bearer ${trimmed}`
: getClineAuthorizationHeader(trimmed);
const headers = {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"User-Agent": `9Router/${APP_VERSION}`,
"X-PLATFORM": process.platform || "unknown",
"X-PLATFORM-VERSION": process.version || "unknown",
"X-CLIENT-TYPE": "9router",
"X-CLIENT-VERSION": APP_VERSION,
"X-CORE-VERSION": APP_VERSION,
"User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
"X-PLATFORM": "cli",
"X-PLATFORM-VERSION": CLINE_CLIENT_VERSION,
"X-CLIENT-TYPE": CLINE_CLIENT_TYPE,
"X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
"X-CORE-VERSION": CLINE_CLIENT_VERSION,
"X-IS-MULTIROOT": "false",
...extraHeaders,
};
+8 -1
View File
@@ -69,7 +69,14 @@ export async function parseUpstreamError(response, executor = null) {
const parsed = executor.parseError(response, bodyText);
if (parsed && typeof parsed === "object") {
const msg = parsed.message || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`;
return { statusCode: parsed.status || response.status, message: msg, resetsAtMs: parsed.resetsAtMs };
return {
statusCode: parsed.status || response.status,
message: msg,
resetsAtMs: parsed.resetsAtMs,
// Executors declare IP/pool-scoped failures (e.g. per-IP rate limits)
// here; chatCore completes poolId/scope and retries via another pool.
poolScoped: parsed.poolScoped,
};
}
} catch { /* fall through to default parsing */ }
}
-2
View File
@@ -381,8 +381,6 @@ export function createSSEStream(options = {}) {
},
flush(controller) {
const evtSummary = Object.entries(eventTypeCounts).map(([k, v]) => `${k}=${v}`).join(",") || "none";
dbg("SSE", `flush | provider=${provider} | model=${model} | recvLines=${sseLineCount} | emitted=${sseEmittedCount} | events=[${evtSummary}]`);
trackPendingRequest(model, provider, connectionId, false);
try {
const remaining = decoder.decode();
+6 -22
View File
@@ -1,6 +1,6 @@
// Stream handler with disconnect detection - shared for all providers
import { STREAM_STALL_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { dbg, isDebugEnabled } from "./debugLog.js";
import { dbg } from "./debugLog.js";
// Get HH:MM:SS timestamp
function getTimeString() {
@@ -79,7 +79,7 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
return;
}
logStream("✗", `ERROR: ${error.message}${error.stack ? `\n ${error.stack}` : ""}`, true);
logStream("✗", `ERROR: ${error.message}`, true);
onError?.(error);
},
@@ -194,12 +194,7 @@ export function createDisconnectAwareStream(transformStream, streamController, o
*/
export function pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal = null, stallTimeoutMs = STREAM_STALL_TIMEOUT_MS) {
let stallTimer = null;
let chunkCount = 0;
let totalBytes = 0;
let lastChunkAt = Date.now();
let abortMessage = "upstream connection lost";
const t0 = Date.now();
const tag = "STREAM";
const clearStall = () => {
if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; }
};
@@ -208,7 +203,6 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
stallTimer = setTimeout(() => {
stallTimer = null;
abortMessage = "stream stall timeout";
dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`);
streamController.handleError?.(new Error("stream stall timeout"));
streamController.abort?.();
}, stallTimeoutMs);
@@ -221,30 +215,20 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont
signal: streamController.signal,
startTime: streamController.startTime,
isConnected: () => streamController.isConnected(),
handleComplete: () => { dbg(tag, `complete | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleComplete(); },
handleError: (e) => { dbg(tag, `error: ${e?.message} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleError(e); },
handleDisconnect: (r) => { dbg(tag, `disconnect: ${r} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleDisconnect(r); },
handleComplete: () => { clearStall(); streamController.handleComplete(); },
handleError: (e) => { clearStall(); streamController.handleError(e); },
handleDisconnect: (r) => { clearStall(); streamController.handleDisconnect(r); },
abort: () => { clearStall(); streamController.abort(); }
};
armStall();
dbg(tag, `pipe start | stallTimeout=${stallTimeoutMs}ms`);
const upstreamTap = new TransformStream({
transform(chunk, controller) {
chunkCount++;
const sz = chunk?.byteLength || chunk?.length || 0;
totalBytes += sz;
const now = Date.now();
const gap = now - lastChunkAt;
lastChunkAt = now;
if (isDebugEnabled && (chunkCount <= 5 || chunkCount % 20 === 0 || gap > 5000)) {
dbg(tag, `chunk #${chunkCount} | size=${sz}B | gap=${gap}ms | total=${totalBytes}B`);
}
armStall();
controller.enqueue(chunk);
},
flush() { dbg(tag, `upstream EOF | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); }
flush() { clearStall(); }
});
const transformedBody = providerResponse.body
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.86",
"version": "0.5.86",
"description": "9Router web dashboard",
"private": true,
"scripts": {
@@ -28,12 +28,13 @@
"bcryptjs": "^3.0.3",
"chalk": "^5.6.2",
"confbox": "^0.2.4",
"dompurify": "^3.4.13",
"express": "^5.2.1",
"http-proxy-middleware": "^3.0.5",
"jose": "^6.1.3",
"marked": "^18.0.1",
"material-symbols": "^0.44.6",
"monaco-editor": "^0.55.1",
"monaco-editor": "^0.56.0",
"next": "^16.1.6",
"node-forge": "^1.3.3",
"node-machine-id": "^1.1.12",
@@ -61,5 +62,8 @@
"eslint-config-next": "16.1.6",
"postcss": "^8.5.6",
"tailwindcss": "^4"
},
"overrides": {
"dompurify": "^3.4.13"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+6 -1
View File
@@ -2,9 +2,14 @@
// Sends text / image / search requests to a combo and reports which member ran.
// node scripts/test-combo-autoswitch.mjs
const BASE = process.env.BASE_URL || "http://localhost:20127";
const KEY = process.env.API_KEY || "sk-6581be4f05a82b6b-uxy6jn-c8190ea8";
const KEY = process.env.API_KEY;
const COMBO = process.env.COMBO || "haha";
if (!KEY) {
console.error("API_KEY environment variable is required");
process.exit(1);
}
// 16x16 PNG (valid image so vision providers accept it).
const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4I2JDEmIY1TCqYfhqAAAeBCwQ8YdREQAAAABJRU5ErkJggg==";
@@ -10,6 +10,16 @@ import { APP_CONFIG } from "@/shared/constants/config";
import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config";
import { LOCALE_FLAGS } from "@/shared/constants/locales";
function memoryLine(m) {
if (!m) return "";
const fb = m.inMemory?.freebuff || {};
return [
`DB: ${m.dbPath || "-"} (${m.dbSizeMB || "-"})`,
`Data dir: ${m.dataDirSizeMB || "-"}`,
`In-memory: fitness=${m.inMemory?.fitnessPools ?? 0} pool(s) · geo=${m.inMemory?.geoPools ?? 0} pool(s) · freebuff sessions=${fb.sessions ?? 0}, locks=${fb.modelLocks ?? 0}, pool-limits=${fb.poolLimits ?? 0}`,
].join("\n");
}
function getLocaleFromCookie() {
if (typeof document === "undefined") return "en";
const cookie = document.cookie
@@ -72,6 +82,8 @@ export default function ProfilePage() {
const certFileRef = useRef(null);
const importFileRef = useRef(null);
const [memoryInfo, setMemoryInfo] = useState(null);
const [memoryLoading, setMemoryLoading] = useState(false);
const [proxyForm, setProxyForm] = useState({
outboundProxyEnabled: false,
outboundProxyUrl: "",
@@ -752,6 +764,19 @@ export default function ProfilePage() {
setShutdownOpen(false);
};
const handleMemoryCheck = async () => {
setMemoryLoading(true);
try {
const res = await fetch("/api/system/memory", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setMemoryInfo(await res.json());
} catch (err) {
setMemoryInfo({ error: err.message });
} finally {
setMemoryLoading(false);
}
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", { method: "POST" });
@@ -825,6 +850,15 @@ export default function ProfilePage() {
>
Import Backup
</Button>
<Button
variant="outline"
icon="memory"
onClick={handleMemoryCheck}
loading={memoryLoading}
className="w-full sm:w-auto"
>
Check Size Memory
</Button>
<input
ref={importFileRef}
type="file"
@@ -838,6 +872,13 @@ export default function ProfilePage() {
{dbStatus.message}
</p>
)}
{memoryInfo && (
<pre className="text-[11px] font-mono text-text-muted whitespace-pre-wrap break-all">
{memoryInfo.error
? `Error: ${memoryInfo.error}`
: memoryLine(memoryInfo)}
</pre>
)}
</div>
</Card>
@@ -1646,6 +1687,14 @@ export default function ProfilePage() {
<div className="text-center text-xs sm:text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
<p className="mt-1">{isRemoteHost ? "Remote Mode" : "Local Mode - All data stored on your machine"}</p>
<a
href="https://github.com/mhiqrambg/9router-mibp-version"
target="_blank"
rel="noreferrer"
className="inline-block mt-1 text-[11px] hover:text-primary transition-colors"
>
MIBP Edition
</a>
</div>
</div>
@@ -6,30 +6,76 @@ import PropTypes from "prop-types";
import { Badge, Toggle, Tooltip } from "@/shared/components";
import CooldownTimer from "./CooldownTimer";
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null, modelAssignmentOptions = null, onModelAssignmentChange = null, strictModelAssignment = false }) {
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
const [updatingProxy, setUpdatingProxy] = useState(false);
const [selectedProxyIds, setSelectedProxyIds] = useState([]);
const [rotationStrategy, setRotationStrategy] = useState("none");
const proxyDropdownRef = useRef(null);
// Initialize proxy state from connection
useEffect(() => {
const proxyPoolIds = connection.providerSpecificData?.proxyPoolIds || [];
const legacyProxyPoolId = connection.providerSpecificData?.proxyPoolId;
// Migrate legacy single proxy to array format
queueMicrotask(() => {
if (legacyProxyPoolId && proxyPoolIds.length === 0) {
setSelectedProxyIds([legacyProxyPoolId]);
} else {
setSelectedProxyIds(proxyPoolIds);
}
setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none");
});
}, [connection]);
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null;
const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null;
// Display logic - support both new (multi-proxy) and legacy (single proxy) formats
const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl;
const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy;
const proxyDisplayText = boundProxyPool
? `Pool: ${boundProxyPool.name}`
: boundProxyPoolId
? `Pool: ${boundProxyPoolId} (inactive/missing)`
: hasLegacyProxy
? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`
: "";
const hasAnyProxy = selectedProxyIds.length > 0 || hasLegacyProxy;
const getProxyDisplayText = () => {
if (selectedProxyIds.length === 0 && !hasLegacyProxy) return "";
if (selectedProxyIds.length === 1) {
const pool = proxyPoolMap.get(selectedProxyIds[0]);
return pool ? `Pool: ${pool.name}` : `Pool: ${selectedProxyIds[0]} (inactive/missing)`;
}
if (selectedProxyIds.length > 1) {
const strategyLabel = rotationStrategy === "random" ? "Random" : rotationStrategy === "round-robin" ? "Round Robin" : rotationStrategy === "failover" ? "Failover" : rotationStrategy === "smart" ? "Smart" : "Multiple";
return `${selectedProxyIds.length} pools (${strategyLabel})`;
}
if (hasLegacyProxy) {
return `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`;
}
return "";
};
const proxyDisplayText = getProxyDisplayText();
const autoPingTooltip = autoPing?.provider === "codex"
? "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota."
: "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away.";
let maskedProxyUrl = "";
if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) {
const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl;
if (selectedProxyIds.length > 0) {
const selectedPools = selectedProxyIds.map(id => proxyPoolMap.get(id)).filter(Boolean);
if (selectedPools.length > 0) {
try {
const parsed = new URL(selectedPools[0].proxyUrl);
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
if (selectedPools.length > 1) {
maskedProxyUrl += ` (+${selectedPools.length - 1} more)`;
}
} catch {
maskedProxyUrl = selectedPools[0].proxyUrl;
}
}
} else if (connection.providerSpecificData?.connectionProxyUrl) {
const rawProxyUrl = connection.providerSpecificData?.connectionProxyUrl;
try {
const parsed = new URL(rawProxyUrl);
maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
@@ -38,12 +84,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
}
}
const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || "";
const noProxyText = selectedProxyIds.length > 0
? proxyPoolMap.get(selectedProxyIds[0])?.noProxy || ""
: connection.providerSpecificData?.connectionNoProxy || "";
let proxyBadgeVariant = "default";
if (boundProxyPool?.isActive === true) {
proxyBadgeVariant = "success";
} else if (boundProxyPoolId || hasLegacyProxy) {
if (selectedProxyIds.length > 0) {
const allActive = selectedProxyIds.every(id => proxyPoolMap.get(id)?.isActive === true);
proxyBadgeVariant = allActive ? "success" : "error";
} else if (hasLegacyProxy) {
proxyBadgeVariant = "error";
}
@@ -59,10 +108,54 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
return () => document.removeEventListener("mousedown", handler);
}, [showProxyDropdown]);
const handleSelectProxy = async (poolId) => {
const handleToggleProxySelection = (poolId) => {
setSelectedProxyIds(prev => {
if (prev.includes(poolId)) {
return prev.filter(id => id !== poolId);
} else {
return [...prev, poolId];
}
});
};
const handleStrategyChange = (strategy) => {
setRotationStrategy(strategy);
// Auto-select all active proxy pools when switching to rotation strategies
if (strategy !== "none" && selectedProxyIds.length === 0) {
const activePoolIds = (proxyPools || [])
.filter(pool => pool.isActive === true)
.map(pool => pool.id);
setSelectedProxyIds(activePoolIds);
}
// Reset to single proxy when switching to "none"
if (strategy === "none" && selectedProxyIds.length > 1) {
setSelectedProxyIds(selectedProxyIds.slice(0, 1));
}
};
const handleApplyProxyChanges = async () => {
setUpdatingProxy(true);
try {
await onUpdateProxy(poolId === "__none__" ? null : poolId);
await onUpdateProxy({
proxyPoolIds: selectedProxyIds,
proxyRotationStrategy: rotationStrategy,
});
setShowProxyDropdown(false);
} finally {
setUpdatingProxy(false);
}
};
const handleSelectProxy = async (poolId) => {
// Legacy single-proxy mode (backwards compatibility)
setUpdatingProxy(true);
try {
await onUpdateProxy({
proxyPoolIds: poolId === "__none__" ? [] : [poolId],
proxyRotationStrategy: "none",
});
} finally {
setUpdatingProxy(false);
setShowProxyDropdown(false);
@@ -109,7 +202,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
return () => {
if (interval) clearInterval(interval);
};
}, [modelLockUntil]);
}, [connection, modelLockUntil]);
// Determine effective status (override unavailable if cooldown expired)
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
@@ -191,6 +284,20 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
</Badge>
)}
</div>
{modelAssignmentOptions && onModelAssignmentChange && (
<select
value={connection.providerSpecificData?.assignedModel || connection.providerSpecificData?.freebuffModel || ""}
onChange={(e) => onModelAssignmentChange(e.target.value)}
disabled={!strictModelAssignment}
className="mt-2 max-w-full rounded-md border border-border bg-background px-2 py-1 text-[11px] text-text-main"
title="Model assignment"
>
<option value="">Unassigned</option>
{modelAssignmentOptions.map((model) => (
<option key={model.id} value={model.id}>{model.name || model.id}</option>
))}
</select>
)}
{hasAnyProxy && (
<div className="mt-1 flex items-center gap-2 flex-wrap">
<span className="max-w-full truncate text-[11px] text-text-muted sm:max-w-[420px]" title={proxyDisplayText}>
@@ -226,22 +333,119 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
<span className="text-[10px] leading-tight">Proxy</span>
</button>
{showProxyDropdown && (
<div className="absolute right-0 top-full z-50 mt-1 max-w-[78vw] min-w-[160px] rounded-lg border border-border bg-bg py-1 shadow-lg">
<button
onClick={() => handleSelectProxy("__none__")}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${!boundProxyPoolId ? "text-primary font-medium" : "text-text-main"}`}
>
None
</button>
{(proxyPools || []).map((pool) => (
<button
key={pool.id}
onClick={() => handleSelectProxy(pool.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${boundProxyPoolId === pool.id ? "text-primary font-medium" : "text-text-main"}`}
<div className="absolute left-0 top-full z-50 mt-1 max-w-[calc(100vw-2rem)] min-w-[280px] rounded-lg border border-border bg-bg shadow-lg sm:left-auto sm:right-0">
{/* Rotation Strategy Selector */}
<div className="border-b border-border p-3">
<label className="block text-xs font-medium text-text-muted mb-2">Rotation Strategy</label>
<select
value={rotationStrategy}
onChange={(e) => handleStrategyChange(e.target.value)}
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text-main focus:border-primary focus:outline-none"
>
{pool.name}
<option value="none">None (Single Proxy)</option>
<option value="random">Random</option>
<option value="round-robin">Round Robin</option>
<option value="failover">Failover</option>
<option value="smart">Smart</option>
</select>
{rotationStrategy !== "none" && (
<p className="mt-1 text-[10px] text-text-muted">
{rotationStrategy === "random" && "Randomly select proxy on each request"}
{rotationStrategy === "round-robin" && "Rotate proxies in order across requests"}
{rotationStrategy === "failover" && "Try next proxy on failure"}
{rotationStrategy === "smart" && "Skip pools whose egress IP is blocked for this provider/model (e.g. Freebuff limited-IP). See Proxy Fitness to clear/block."}
</p>
)}
</div>
{/* Proxy Pool Selection */}
<div className="max-h-[200px] overflow-y-auto py-1">
<button
onClick={() => {
setSelectedProxyIds([]);
setRotationStrategy("none");
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${selectedProxyIds.length === 0 ? "text-primary font-medium" : "text-text-main"}`}
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">
{selectedProxyIds.length === 0 ? "check_box" : "check_box_outline_blank"}
</span>
<span>None</span>
</div>
</button>
))}
{/* Select All Button (only for rotation strategies) */}
{rotationStrategy !== "none" && (
<button
onClick={() => {
const activePoolIds = (proxyPools || [])
.filter(pool => pool.isActive === true)
.map(pool => pool.id);
const allSelected = activePoolIds.length > 0 && activePoolIds.every(id => selectedProxyIds.includes(id));
if (allSelected) {
setSelectedProxyIds([]);
} else {
setSelectedProxyIds(activePoolIds);
}
}}
className={`w-full text-left px-3 py-2 text-sm border-b border-border hover:bg-black/5 dark:hover:bg-white/5 ${selectedProxyIds.length === (proxyPools || []).filter(p => p.isActive).length ? "bg-black/5 dark:bg-white/5" : ""}`}
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">
{selectedProxyIds.length === (proxyPools || []).filter(p => p.isActive).length && selectedProxyIds.length > 0
? "check_box"
: "check_box_outline_blank"
}
</span>
<span className="font-medium">Select All Active</span>
</div>
</button>
)}
{(proxyPools || []).map((pool) => {
const isSelected = selectedProxyIds.includes(pool.id);
const isActive = pool.isActive === true;
return (
<button
key={pool.id}
onClick={() => {
if (rotationStrategy === "none") {
setSelectedProxyIds([pool.id]);
} else {
handleToggleProxySelection(pool.id);
}
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${isSelected ? "bg-black/5 dark:bg-white/5" : ""}`}
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">
{rotationStrategy === "none"
? (isSelected ? "radio_button_checked" : "radio_button_unchecked")
: (isSelected ? "check_box" : "check_box_outline_blank")
}
</span>
<span className={isSelected ? "text-primary font-medium" : "text-text-main"}>{pool.name}</span>
{!isActive && (
<span className="ml-auto text-[10px] text-red-500">(inactive)</span>
)}
</div>
</button>
);
})}
</div>
{/* Apply Button */}
<div className="border-t border-border p-2">
<button
onClick={handleApplyProxyChanges}
disabled={updatingProxy}
className="w-full rounded bg-primary px-3 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updatingProxy ? "Applying..." : "Apply"}
</button>
</div>
</div>
)}
</div>
@@ -306,6 +510,9 @@ ConnectionRow.propTypes = {
onUpdateProxy: PropTypes.func,
onEdit: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
modelAssignmentOptions: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string })),
onModelAssignmentChange: PropTypes.func,
strictModelAssignment: PropTypes.bool,
oneByOneStatus: PropTypes.shape({
state: PropTypes.string,
error: PropTypes.string,
@@ -67,6 +67,7 @@ export default function ProviderDetailPage() {
const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false);
const [providerStrategy, setProviderStrategy] = useState(null);
const [providerStickyLimit, setProviderStickyLimit] = useState("");
const [strictModelAssignment, setStrictModelAssignment] = useState(false);
const [thinkingMode, setThinkingMode] = useState("auto");
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
const [suggestedModels, setSuggestedModels] = useState([]);
@@ -181,6 +182,21 @@ export default function ProviderDetailPage() {
return levels && levels.includes(thinkingMode) ? thinkingMode : null;
};
const providerStorageAlias = isCompatible ? providerId : providerAlias;
const assignmentModels = (() => {
const byId = new Map();
const add = (model) => {
if (model?.id && !byId.has(model.id)) byId.set(model.id, model);
};
models.forEach(add);
kiloFreeModels.forEach(add);
customModels.forEach((model) => {
if (model.providerAlias === providerStorageAlias && (model.kind || model.type || "llm") === "llm") {
add(model);
}
});
const disabled = new Set(disabledModelIds);
return [...byId.values()].filter((model) => !disabled.has(model.id));
})();
// Union of levels across this provider's reasoning models — drives the level picker options.
// Include custom models too (e.g. manually added gpt-5.6-sol → max).
const providerThinkingLevels = (() => {
@@ -324,6 +340,7 @@ export default function ProviderDetailPage() {
const override = (settingsData.providerStrategies || {})[providerId] || {};
setProviderStrategy(override.fallbackStrategy || null);
setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1");
setStrictModelAssignment(override.strictModelAssignment === true);
// Load per-provider thinking config
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
setThinkingMode(thinkingCfg.mode || "auto");
@@ -379,9 +396,13 @@ export default function ProviderDetailPage() {
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
// Build override: null strategy means remove override, use global
const override = {};
// Preserve Freebuff-only settings while changing the shared strategy.
const override = { ...(current[providerId] || {}) };
if (strategy) override.fallbackStrategy = strategy;
else {
delete override.fallbackStrategy;
delete override.stickyRoundRobinLimit;
}
if (strategy === "round-robin" && stickyLimit !== "") {
override.stickyRoundRobinLimit = Number(stickyLimit) || 3;
}
@@ -403,6 +424,44 @@ export default function ProviderDetailPage() {
}
};
const handleStrictAssignmentToggle = async (enabled) => {
setStrictModelAssignment(enabled);
try {
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
const current = settingsData.providerStrategies || {};
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerStrategies: {
...current,
[providerId]: { ...(current[providerId] || {}), strictModelAssignment: enabled },
},
}),
});
} catch (error) {
console.log("Error saving Freebuff strict assignment:", error);
}
};
const handleModelAssignment = async (connectionId, assignedModel) => {
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerSpecificData: { assignedModel: assignedModel || null } }),
});
if (res.ok) {
setConnections((prev) => prev.map((c) => c.id === connectionId
? { ...c, providerSpecificData: { ...(c.providerSpecificData || {}), assignedModel: assignedModel || null } }
: c));
}
} catch (error) {
console.log("Error saving Freebuff model assignment:", error);
}
};
const handleRoundRobinToggle = (enabled) => {
const strategy = enabled ? "round-robin" : null;
const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit;
@@ -463,10 +522,12 @@ export default function ProviderDetailPage() {
};
useEffect(() => {
fetchConnections();
fetchAliases();
fetchCustomModels();
fetchDisabledModels();
Promise.resolve().then(() => {
fetchConnections();
fetchAliases();
fetchCustomModels();
fetchDisabledModels();
});
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]);
// Live per-connection catalogs (cursor, zed): the static registry carries
@@ -476,13 +537,13 @@ export default function ProviderDetailPage() {
useEffect(() => {
const isLiveCatalog = providerId === "cursor" || providerId === "zed";
if (!isLiveCatalog) {
setLiveModels([]);
queueMicrotask(() => setLiveModels([]));
return;
}
const connection = connections.find((item) => item.isActive !== false);
if (!connection?.id) {
setLiveModels([]);
queueMicrotask(() => setLiveModels([]));
if (providerId === "zed") setLiveModelsError(null);
return;
}
@@ -942,7 +1003,9 @@ export default function ProviderDetailPage() {
};
useEffect(() => {
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
queueMicrotask(() => {
setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id)));
});
}, [connections]);
const selectedProxySummary = (() => {
@@ -1043,17 +1106,39 @@ export default function ProviderDetailPage() {
onToggle: (on) => handleAutoPingConnection(conn.id, on),
provider: providerId,
} : null}
onUpdateProxy={async (proxyPoolId) => {
onUpdateProxy={async (proxyConfig) => {
try {
// Support both new format (object) and legacy format (string/null)
const updatePayload = typeof proxyConfig === 'object' && proxyConfig !== null
? {
proxyPoolIds: proxyConfig.proxyPoolIds || [],
proxyRotationStrategy: proxyConfig.proxyRotationStrategy || "none",
}
: {
// Legacy single-proxy format
proxyPoolId: proxyConfig || null,
};
const res = await fetch(`/api/providers/${conn.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxyPoolId: proxyPoolId || null }),
body: JSON.stringify(updatePayload),
});
if (res.ok) {
setConnections(prev => prev.map(c =>
c.id === conn.id
? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } }
? {
...c,
providerSpecificData: {
...c.providerSpecificData,
...(updatePayload.proxyPoolIds !== undefined ? {
proxyPoolIds: updatePayload.proxyPoolIds,
proxyRotationStrategy: updatePayload.proxyRotationStrategy,
} : {
proxyPoolId: updatePayload.proxyPoolId,
})
}
}
: c
));
}
@@ -1067,6 +1152,9 @@ export default function ProviderDetailPage() {
}}
onDelete={() => handleDelete(conn.id)}
oneByOneStatus={oneByOneResults[conn.id] || null}
modelAssignmentOptions={assignmentModels}
onModelAssignmentChange={(model) => handleModelAssignment(conn.id, model)}
strictModelAssignment={strictModelAssignment}
/>
</div>
</div>
@@ -1578,6 +1666,13 @@ export default function ProviderDetailPage() {
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-black/[0.03] pt-2 dark:border-white/[0.03]">
<div>
<span className="text-xs text-text-muted font-medium">Strict Model Assignment</span>
<p className="text-[10px] text-text-muted">Only assigned accounts can serve each model for this provider.</p>
</div>
<Toggle checked={strictModelAssignment} onChange={handleStrictAssignmentToggle} />
</div>
</div>
</div>
@@ -0,0 +1,424 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Badge, Button, Card, CardSkeleton, Input, ConfirmModal, Toggle } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { useNotificationStore } from "@/store/notificationStore";
function fmtTime(value) {
if (!value) return "-";
const d = new Date(value);
return Number.isNaN(d.getTime()) ? "-" : d.toLocaleTimeString();
}
// "freebuff::openai/gpt-5.6-luna" -> { provider: "freebuff", model: "openai/gpt-5.6-luna" }
// "freebuff::*" -> { provider: "freebuff", model: null }
function parseScope(scope) {
const sep = String(scope || "").indexOf("::");
if (sep < 0) return { provider: String(scope || ""), model: null };
const provider = scope.slice(0, sep);
const model = scope.slice(sep + 2);
return { provider, model: model === "*" || model === "" ? null : model };
}
function maskProxyUrl(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname || "";
const port = parsed.port ? `:${parsed.port}` : "";
return `${parsed.protocol}//${host}${port}`;
} catch {
return String(url || "");
}
}
// Flatten fitness snapshot into one record per (pool, scope); drops expired marks.
function buildRecords(fitness, pools, now = Date.now()) {
const poolById = new Map((pools || []).map((p) => [p.id, p]));
const records = [];
for (const [poolId, byScope] of Object.entries(fitness || {})) {
const pool = poolById.get(poolId);
for (const [scope, info] of Object.entries(byScope || {})) {
const until = info?.until ? new Date(info.until).getTime() : 0;
if (!until || until <= now) continue;
const { provider, model } = parseScope(scope);
records.push({
poolId,
scope,
provider,
model,
until,
reason: info?.reason || "blocked",
poolName: pool?.name || poolId.slice(0, 8),
proxyUrl: pool?.proxyUrl || "",
egress: pool?.egress || null,
});
}
}
return records;
}
export default function ProxyFitnessPage() {
const [pools, setPools] = useState([]);
const [fitness, setFitness] = useState({});
const [loading, setLoading] = useState(true);
const [providerFilter, setProviderFilter] = useState("all");
const [search, setSearch] = useState("");
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
const [providerMenuDropUp, setProviderMenuDropUp] = useState(false);
const [clearingScope, setClearingScope] = useState(null); // poolId::scope while clearing
const [confirmClearAll, setConfirmClearAll] = useState(false);
const [clearingAll, setClearingAll] = useState(false);
const [geoEnabled, setGeoEnabled] = useState(true);
const [geoUpdating, setGeoUpdating] = useState(false);
const providerMenuRef = useRef(null);
const notify = useNotificationStore();
useEffect(() => {
const handleClickOutside = (e) => {
if (providerMenuRef.current && !providerMenuRef.current.contains(e.target)) {
setProviderMenuOpen(false);
}
};
if (providerMenuOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [providerMenuOpen]);
const fetchAll = useCallback(async () => {
try {
const [poolRes, fitRes] = await Promise.all([
fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" }),
fetch("/api/proxy-pools/fitness", { cache: "no-store" }),
]);
const poolData = await poolRes.json().catch(() => ({ proxyPools: [] }));
setPools(poolData.proxyPools || []);
if (fitRes.ok) {
const fitData = await fitRes.json().catch(() => ({}));
setFitness(fitData.pools || fitData.fitness || {});
} else {
setFitness({});
}
} catch (error) {
console.log("Error fetching proxy fitness:", error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAll();
}, [fetchAll]);
useEffect(() => {
fetch("/api/settings", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : {}))
.then((s) => {
if (typeof s.poolGeoProbeEnabled === "boolean") setGeoEnabled(s.poolGeoProbeEnabled);
})
.catch(() => {});
}, []);
const handleGeoToggle = async (next) => {
setGeoUpdating(true);
setGeoEnabled(next);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ poolGeoProbeEnabled: next }),
});
if (!res.ok) {
setGeoEnabled(!next);
throw new Error(`HTTP ${res.status}`);
}
notify.success(next ? "Geo probe enabled" : "Geo probe disabled");
} catch (err) {
notify.error(`Update failed: ${err.message}`);
} finally {
setGeoUpdating(false);
}
};
const records = useMemo(() => buildRecords(fitness, pools), [fitness, pools]);
// Providers present in the fitness data (filter options)
const providerOptions = useMemo(() => {
const set = new Set();
for (const rec of records) {
if (rec.provider) set.add(rec.provider);
}
return Array.from(set).sort();
}, [records]);
const filteredRecords = useMemo(() => {
const q = search.trim().toLowerCase();
return records.filter((rec) => {
if (providerFilter !== "all" && rec.provider !== providerFilter) return false;
if (q) {
const hay = [rec.proxyUrl, rec.egress?.ip, rec.egress?.country, rec.egress?.region, rec.poolName]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [records, providerFilter, search]);
const handleClear = async (rec) => {
setClearingScope(rec.scope);
try {
const res = await fetch(`/api/proxy-pools/${rec.poolId}/fitness/clear`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope: rec.scope }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
notify.success(`Cleared ${rec.scope}`);
fetchAll();
} catch (err) {
notify.error(`Clear failed: ${err.message}`);
} finally {
setClearingScope(null);
}
};
const handleClearAll = async () => {
setClearingAll(true);
try {
const res = await fetch("/api/proxy-pools/fitness/clear-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(providerFilter !== "all" ? { provider: providerFilter } : {}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
notify.success(providerFilter !== "all" ? `Cleared all ${providerFilter} blocks` : "Cleared all blocks");
setConfirmClearAll(false);
fetchAll();
} catch (err) {
notify.error(`Clear all failed: ${err.message}`);
} finally {
setClearingAll(false);
}
};
const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter;
// Open the provider menu as an overlay anchored to its container. Flips to
// drop-up when there isn't enough viewport space below (menu max-h ~288px).
const toggleProviderMenu = () => {
if (providerMenuOpen) {
setProviderMenuOpen(false);
return;
}
const el = providerMenuRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setProviderMenuDropUp(window.innerHeight - rect.bottom < 300);
}
setProviderMenuOpen(true);
};
return (
<div className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
{loading ? (
<CardSkeleton />
) : (
<>
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-text-main">Proxy Fitness</h1>
<Badge variant={records.length > 0 ? "error" : "default"} size="sm">
{records.length} active block{records.length === 1 ? "" : "s"}
</Badge>
</div>
<div className="flex items-center gap-2">
{records.length > 0 && (
<Button
variant="danger"
size="sm"
icon="delete_sweep"
onClick={() => setConfirmClearAll(true)}
disabled={clearingAll}
>
Clear All{providerFilter !== "all" ? ` (${providerFilter})` : ""}
</Button>
)}
<Toggle
size="sm"
checked={geoEnabled}
onChange={handleGeoToggle}
disabled={geoUpdating}
label="Geo probe"
description="Probe egress IP/country per pool every ~30 min (uses geo-API quota)"
/>
<Button variant="secondary" size="sm" icon="refresh" onClick={fetchAll}>Refresh</Button>
</div>
</div>
<p className="text-sm text-text-muted">
Shows which provider is blocked on which proxy IP (from provider region gates, per-IP
limits, or manual marks). Smart rotation skips these pools while the block is active.
</p>
{/* Filters */}
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border-subtle bg-surface p-3">
<div className="relative flex w-64 max-w-full flex-col" ref={providerMenuRef}>
<label className="mb-1 text-xs font-medium text-text-muted">Provider</label>
<button
type="button"
onClick={toggleProviderMenu}
className="flex h-8 w-full items-center justify-between gap-1 rounded-lg border border-black/10 bg-black/[0.02] px-2 text-xs text-text-main transition-colors hover:bg-black/5 dark:border-white/10 dark:bg-white/[0.03] dark:hover:bg-white/10"
aria-haspopup="menu"
aria-expanded={providerMenuOpen}
title="Filter by provider"
>
<span className="flex min-w-0 items-center gap-1.5">
{providerFilter === "all" ? (
<span className="material-symbols-outlined text-[14px] text-text-muted">apps</span>
) : (
<ProviderIcon providerId={providerFilter} size={18} className="size-[18px] rounded object-contain" fallbackText={providerFilter.slice(0, 2).toUpperCase()} />
)}
<span className="truncate capitalize">{selectedProviderLabel}</span>
</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">expand_more</span>
</button>
{providerMenuOpen && (
<div className={`absolute left-0 z-20 w-full max-h-72 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-1 shadow-lg ${providerMenuDropUp ? "bottom-full mb-1" : "top-full mt-1"}`}>
<button
type="button"
onClick={() => { setProviderFilter("all"); setProviderMenuOpen(false); }}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === "all" ? "bg-primary/10 text-primary" : "text-text-main hover:bg-black/5 dark:hover:bg-white/10"}`}
>
<span className="material-symbols-outlined text-[22px]">apps</span>
<span className="font-medium">All providers</span>
{providerFilter === "all" && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
</button>
<div className="my-1 h-px bg-black/10 dark:bg-white/10" />
{providerOptions.map((provider) => (
<button
key={provider}
type="button"
onClick={() => { setProviderFilter(provider); setProviderMenuOpen(false); }}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm transition-colors ${providerFilter === provider ? "bg-primary/10 text-primary" : "text-text-main hover:bg-black/5 dark:hover:bg-white/10"}`}
>
<ProviderIcon providerId={provider} size={24} className="size-6 rounded-md object-contain" fallbackText={provider.slice(0, 2).toUpperCase()} />
<span className="font-medium capitalize">{provider}</span>
{providerFilter === provider && <span className="material-symbols-outlined ml-auto text-[20px]">check</span>}
</button>
))}
</div>
)}
</div>
<div className="flex min-w-48 flex-1 flex-col">
<label className="mb-1 text-xs font-medium text-text-muted">IP / Proxy / Pool</label>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="e.g. 104.28, vercel-relay…"
icon="search"
/>
</div>
</div>
{/* Records table */}
<Card className="p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[700px] border-collapse text-sm">
<thead>
<tr className="border-b border-border-subtle text-left text-xs uppercase tracking-wider text-text-muted">
<th className="px-4 py-2 font-semibold">Provider</th>
<th className="px-4 py-2 font-semibold">Model</th>
<th className="px-4 py-2 font-semibold">IP / Proxy</th>
<th className="px-4 py-2 font-semibold">Pool</th>
<th className="px-4 py-2 font-semibold">Reason</th>
<th className="px-4 py-2 font-semibold">Until</th>
<th className="px-4 py-2 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredRecords.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-text-muted">
{records.length === 0
? "No active blocks. Blocks appear here when a provider region-gates a proxy IP."
: "No blocks match the current filters."}
</td>
</tr>
) : (
filteredRecords.map((rec) => (
<tr key={`${rec.poolId}::${rec.scope}`} className="border-b border-border-subtle last:border-0 align-top hover:bg-surface-2/40">
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2 rounded bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400">
<span className="material-symbols-outlined text-[14px]">block</span>
<span className="capitalize">{rec.provider}</span>
</span>
</td>
<td className="max-w-56 px-4 py-3">
<span className="truncate text-text-main">{rec.model || <em className="text-text-muted">all models</em>}</span>
</td>
<td className="px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<code className="truncate font-mono text-xs text-text-main">{maskProxyUrl(rec.proxyUrl)}</code>
{rec.egress?.ip && (
<span className="flex items-center gap-1 text-[11px] text-text-muted">
<span className="truncate">egress {rec.egress.ip}{rec.egress.country ? ` · ${rec.egress.country}` : ""}</span>
{rec.egress.isUnstable && (
<span
className="inline-flex shrink-0 items-center gap-0.5 rounded bg-amber-500/15 px-1 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
title={rec.egress.ipCount >= 2 ? `Egress IP changed ${rec.egress.ipCount}× — relay egress is not stable` : "Egress is unstable"}
>
unstable
</span>
)}
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-text-muted">{rec.poolName}</td>
<td className="px-4 py-3 text-text-muted">{rec.reason}</td>
<td className="px-4 py-3 text-text-muted">{fmtTime(rec.until)}</td>
<td className="px-4 py-3">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
icon="close"
onClick={() => handleClear(rec)}
disabled={clearingScope === rec.scope}
title="Clear this block"
>
{clearingScope === rec.scope ? "Clearing..." : "Clear"}
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</>
)}
<ConfirmModal
isOpen={confirmClearAll}
onClose={() => setConfirmClearAll(false)}
onConfirm={handleClearAll}
title="Clear all blocks"
message={providerFilter !== "all"
? `Clear all active blocks for provider "${providerFilter}"? This lets those pools be selected again immediately.`
: "Clear all active proxy blocks? This lets all pools be selected again immediately."}
confirmText={clearingAll ? "Clearing..." : "Clear All"}
cancelText="Cancel"
variant="danger"
/>
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { formatResetTime, getRemainingPercentage } from "./utils";
import { formatFreebucksPrice, formatResetTime, getRemainingPercentage } from "./utils";
const PAGE_SIZE = 10;
@@ -171,9 +171,20 @@ export default function QuotaTable({
{/* Name */}
<div className="flex w-36 min-w-0 items-center gap-1.5">
<span className="text-[10px] shrink-0">{colors.emoji}</span>
<span className={`${nameText} font-medium text-text-primary truncate`}>
{quota.name}
</span>
<div className="min-w-0">
<div className={`${nameText} font-medium text-text-primary truncate`}>
{quota.name}
</div>
{quota.price !== undefined && (
<div
className="text-[9px] leading-tight text-text-muted truncate"
title={quota.priceNote || ""}
>
{formatFreebucksPrice(quota.price)}
{quota.priceNote ? ` · ${quota.priceNote}` : ""}
</div>
)}
</div>
</div>
{/* Progress + used/total */}
@@ -9,6 +9,7 @@ import {
parseQuotaData,
calculatePercentage,
filterQuotasByVisibility,
formatFreebucksHeader,
getHiddenQuotaRows,
getQuotaVisibilityKey,
getConnectionLabel,
@@ -1263,6 +1264,11 @@ export default function ProviderLimits() {
</div>
<div className="px-2 py-1.5">
{quota?.raw?.freebucks && !error && !isLoading && (
<div className="mb-1.5 rounded-md bg-black/[0.03] px-2 py-1.5 text-[10px] leading-relaxed text-text-muted dark:bg-white/[0.03]">
{formatFreebucksHeader(quota.raw.freebucks)}
</div>
)}
{isLoading ? (
<div className="text-center py-5 text-text-muted">
<span className="material-symbols-outlined text-[28px] animate-spin">
@@ -258,6 +258,49 @@ export function formatResetTime(date) {
}
}
/**
* Freebucks account header line — mirrors upstream freebucksHeaderLine:
* "10/25 Freebucks daily · resets in 4h 12m · 20 in wallet · $6.50 monthly usage left".
* Wallet shown only when non-empty; monthly only when the server sent it
* (older servers omit it — showing $0 would read as "nothing left").
* @param {{balance: number|null, daily: {limit:number,spent:number,remaining:number,resetAt:string|null}, wallet:{balance:number}, monthly?:{remainingUsd:number,limitUsd:number|null,resetAt:string|null}}} freebucks
* @returns {string|null}
*/
export function formatFreebucksHeader(freebucks) {
if (!freebucks?.daily) return null;
const parts = [
`${Math.max(0, Math.round(freebucks.daily.remaining))}/${Math.max(0, Math.round(freebucks.daily.limit))} Freebucks daily`,
];
const countdown = formatResetTime(freebucks.daily.resetAt);
if (countdown !== "-") parts.push(`resets in ${countdown}`);
if (Number(freebucks.wallet?.balance) > 0) {
parts.push(`${Math.max(0, Math.round(freebucks.wallet.balance))} in wallet`);
}
if (freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd))) {
parts.push(`${formatFreebucksUsd(freebucks.monthly.remainingUsd)} monthly usage left`);
}
return parts.join(" · ");
}
/**
* "$25", "$4.20", "$0" — whole dollars until the figure is small enough that
* the cents are the story. Mirrors upstream formatAllowanceUsd.
*/
export function formatFreebucksUsd(usd) {
const safe = Math.max(0, Number(usd));
if (safe >= 10) return `$${Math.round(safe)}`;
if (safe >= 1) return `$${safe.toFixed(1).replace(/\.0$/, "")}`;
return `$${safe.toFixed(2)}`;
}
/**
* "15 Freebucks/hr" — a bare number would read as dollars; the unit is the
* hour, not the message.
*/
export function formatFreebucksPrice(price) {
return `${Math.max(0, Math.round(Number(price) || 0))} Freebucks/hr`;
}
/**
* Get Tailwind color class based on percentage
* @param {number} percentage - Remaining percentage (0-100)
@@ -685,6 +728,27 @@ export function parseQuotaData(provider, data) {
}
break;
case "freebuff":
// Session quotas keyed by model id — label rows with the friendly
// displayName (from the registry) and keep modelKey for ordering.
// Metered rows carry the live Freebucks price (price) + promo tagline
// (priceNote), both server-authoritative.
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
recurring: quota.recurring !== false,
price: quota.price,
priceNote: quota.priceNote,
});
});
}
break;
case "groq":
// Requests/Tokens rate-limit windows from response headers — absolute
// used/total (calculatePercentage derives the bar), like Codex/Kiro.
@@ -265,6 +265,7 @@ export async function GET(request, { params }) {
"qoder",
"qoder-cn",
"grok-cli",
"freebuff",
];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
+1 -1
View File
@@ -471,7 +471,7 @@ const PROVIDER_MODELS_CONFIG = {
},
"grok-cli": {
customResolver: async (connection) => {
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}, connection.id);
const result = await resolveGrokCliModels({
...connection,
connectionId: connection.id,
+37 -4
View File
@@ -33,7 +33,31 @@ function normalizeProxyConfig(body = {}) {
};
}
async function normalizeProxyPoolUpdate(proxyPoolIdInput) {
async function normalizeProxyPoolUpdate(body) {
// Handle new multi-proxy format
if (body.proxyPoolIds !== undefined) {
const proxyPoolIds = Array.isArray(body.proxyPoolIds) ? body.proxyPoolIds : [];
const proxyRotationStrategy = body.proxyRotationStrategy || "none";
// Validate all proxy pool IDs exist
for (const poolId of proxyPoolIds) {
if (poolId) {
const pool = await getProxyPoolById(poolId);
if (!pool) {
return { hasProxyPoolField: true, error: `Proxy pool ${poolId} not found` };
}
}
}
return {
hasProxyPoolField: true,
proxyPoolIds: proxyPoolIds.filter(Boolean),
proxyRotationStrategy,
};
}
// Handle legacy single proxy format
const proxyPoolIdInput = body.proxyPoolId;
if (proxyPoolIdInput === undefined) {
return { hasProxyPoolField: false, proxyPoolId: null };
}
@@ -111,7 +135,7 @@ export async function PUT(request, { params }) {
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
}
const proxyPoolResult = await normalizeProxyPoolUpdate(body.proxyPoolId);
const proxyPoolResult = await normalizeProxyPoolUpdate(body);
if (proxyPoolResult.error) {
return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 });
}
@@ -147,10 +171,19 @@ export async function PUT(request, { params }) {
}
if (proxyPoolResult.hasProxyPoolField) {
if (proxyPoolResult.proxyPoolId === null) {
// Handle new multi-proxy format
if (proxyPoolResult.proxyPoolIds !== undefined) {
updateData.providerSpecificData.proxyPoolIds = proxyPoolResult.proxyPoolIds;
updateData.providerSpecificData.proxyRotationStrategy = proxyPoolResult.proxyRotationStrategy;
// Clear legacy field
delete updateData.providerSpecificData.proxyPoolId;
} else {
updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId;
// Handle legacy single-proxy format
if (proxyPoolResult.proxyPoolId === null) {
delete updateData.providerSpecificData.proxyPoolId;
} else {
updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId;
}
}
}
}
+40 -4
View File
@@ -19,6 +19,7 @@ import {
KIMCHI_CONFIG,
} from "@/lib/oauth/constants/oauth";
import { buildClineHeaders } from "@/shared/utils/clineAuth";
import { decodeJwtPayload } from "@/lib/oauth/providerHelpers";
// OAuth provider test endpoints
const OAUTH_TEST_CONFIG = {
@@ -100,6 +101,26 @@ const OAUTH_TEST_CONFIG = {
authPrefix: "Bearer ",
},
"codebuddy-cn": { tokenExists: true },
// GUARD — DO NOT REMOVE. See AGENTS.md §4. Without this entry, Test Connection
// returns "Provider test not supported". codebuddy-intl access tokens are
// Keycloak JWTs (iss .../auth/realms/copilot); probe the realm's userinfo
// endpoint so a revoked/expired token is caught. Derive the realm URL from the
// token's `iss` claim, falling back to the known copilot realm.
// 200 = valid, 401 = invalid/revoked.
// Covered by tests/unit/codebuddy-intl-connection.test.js.
"codebuddy-intl": {
buildUrl: (token) => {
const iss = decodeJwtPayload(token)?.iss;
const base = typeof iss === "string" && iss.startsWith("https://")
? iss.replace(/\/$/, "")
: "https://www.codebuddy.ai/auth/realms/copilot";
return `${base}/protocol/openid-connect/userinfo`;
},
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
refreshable: true,
},
kimchi: {
url: KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers",
method: "GET",
@@ -111,9 +132,24 @@ const OAUTH_TEST_CONFIG = {
},
refreshable: false,
},
freebuff: {
// The session endpoint doubles as the auth probe: GET never claims a
// session (POST would burn 1.0 unit of the daily quota). Mirrors the usage
// handler: 401 = bad token, 403 = region/account gate (token still valid),
// 404 = no session row yet (pre-join, token valid). No refresh path —
// when the authToken dies the user re-logs in.
url: "https://www.codebuff.com/api/v1/freebuff/session",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { Accept: "application/json", "User-Agent": "codebuff-cli/0.0.138" },
acceptStatuses: [403, 404],
softFailMessage: {
403: "Connected, but Freebuff is gated (403) — country blocked or account banned.",
},
},
// Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
"grok-cli": {
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
"grok-cli": { url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
@@ -247,7 +283,7 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "codex" || provider === "grok-cli" || provider === "xai") {
if (provider === "codex" || provider === "grok-cli" || provider === "xai" || provider === "codebuddy-intl") {
return await refreshProviderCredentials(provider, connection, console);
}
@@ -849,7 +885,7 @@ export async function testSingleConnection(id) {
const connection = await getProviderConnectionById(id);
if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() };
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}, connection.id);
if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl && !effectiveProxy.vercelRelayUrl) {
const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl });

Some files were not shown because too many files have changed in this diff Show More