Merge origin/master (v0.5.91) into gitea/new_feature

Resolve conflicts:
- streamingHandler.js: adopt upstreamResponseHeaders while keeping 0-token detail row avoidance
- capabilities.js: preserve user-asserted caps and globalThis slots without local caching of catalogSource
- AddCustomModelModal.js & providers/[id]/page.js: wire STT transport marker with custom model edits/assertions
- models/custom/route.js & aliasRepo.js: persist custom model transport and invalidate user caps
- usageRepo.js: key byApiKey live stats by full API key and keep tail in maskApiKey
- UsageStats.js: lazy load charts dynamically
This commit is contained in:
2026-09-28 21:17:33 +07:00
123 changed files with 5882 additions and 411 deletions

176
.github/workflows/tray-binaries.yml vendored Normal file
View File

@@ -0,0 +1,176 @@
name: Build macOS tray binary (arm64)
# systray2 ships only an x86_64 tray_darwin_release, so Apple Silicon users need
# Rosetta 2 for the menubar icon. This builds the native arm64 overlay that
# cli/hooks/trayRuntime.js downloads from the `tray-binaries` release.
#
# Manual-only: the artifact's sha256 is pinned in cli/hooks/trayRuntime.js and
# verified on every download, so a new build is only publishable together with a
# matching pin. Running this with publish=true against a mismatched pin fails
# rather than silently bricking every Apple Silicon client.
on:
workflow_dispatch:
inputs:
publish:
description: "Upload to the tray-binaries release (requires sha to match ARM64_TRAY_SHA256)"
required: false
default: false
type: boolean
concurrency:
group: tray-binaries-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: read
env:
# Pinned because -trimpath only makes the build reproducible for a given Go
# version and macOS SDK. Bumping this changes the sha256.
GO_VERSION: "1.27.1"
jobs:
build:
name: Build darwin/arm64
runs-on: macos-15
timeout-minutes: 20
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
# The Go module lives in a temp clone of the upstream repo, so there is
# no go.sum at the workspace root for setup-go's cache to key on.
cache: false
- name: Record SDK provenance
run: |
{
echo "runner macOS: $(sw_vers -productVersion)"
echo "Xcode: $(xcodebuild -version | head -1)"
echo "clang: $(clang --version | head -1)"
echo "Go: $(go version)"
} | tee sdk-provenance.txt
- name: Build
run: node cli/scripts/buildTrayArm64.js
- name: Compare against pinned checksum
id: sha
run: |
BUILT=$(shasum -a 256 cli/.tray-build/tray_darwin_arm64 | cut -d' ' -f1)
# Whitespace-tolerant, and a missing constant must fail loudly: a null
# match would otherwise surface as an opaque TypeError from [1].
PINNED=$(node -e '
const m = require("fs").readFileSync("cli/hooks/trayRuntime.js", "utf8")
.match(/ARM64_TRAY_SHA256\s*=\s*"([0-9a-f]{64})"/);
if (!m) { console.error("::error::ARM64_TRAY_SHA256 not found in cli/hooks/trayRuntime.js"); process.exit(1); }
process.stdout.write(m[1]);
')
{
echo "built=$BUILT"
echo "pinned=$PINNED"
if [ "$BUILT" = "$PINNED" ]; then echo "match=true"; else echo "match=false"; fi
} >> "$GITHUB_OUTPUT"
- name: Write job summary
run: |
{
echo "### tray_darwin_arm64"
echo ""
echo "| | |"
echo "|---|---|"
echo "| built sha256 | \`${{ steps.sha.outputs.built }}\` |"
echo "| pinned sha256 | \`${{ steps.sha.outputs.pinned }}\` |"
echo "| match | ${{ steps.sha.outputs.match }} |"
echo ""
echo '```'
cat sdk-provenance.txt
echo '```'
echo ""
if [ "${{ steps.sha.outputs.match }}" = "true" ]; then
echo "Pin already matches — safe to re-run with \`publish=true\`."
else
echo "⚠️ Pin does **not** match. To publish this build, set \`ARM64_TRAY_SHA256\`"
echo "in \`cli/hooks/trayRuntime.js\` to the built sha256 above and land that"
echo "change first. Publishing without it makes every Apple Silicon client fail"
echo "checksum verification and fall back to the Rosetta binary."
fi
} >> "$GITHUB_STEP_SUMMARY"
# Uploaded before the mismatch gate below, so a publish run that fails on a
# checksum mismatch still leaves the bytes downloadable — that is exactly
# the run where a maintainer needs them to verify the new sha256.
- uses: actions/upload-artifact@v4
with:
name: tray_darwin_arm64
path: |
cli/.tray-build/tray_darwin_arm64
sdk-provenance.txt
- name: Refuse to publish on checksum mismatch
if: ${{ inputs.publish && steps.sha.outputs.match != 'true' }}
run: |
echo "::error::publish requested but built sha256 != ARM64_TRAY_SHA256"
echo " built: ${{ steps.sha.outputs.built }}"
echo " pinned: ${{ steps.sha.outputs.pinned }}"
echo "Update cli/hooks/trayRuntime.js and land it before publishing."
exit 1
- name: Publish to tray-binaries release
if: ${{ inputs.publish && steps.sha.outputs.match == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Clients fetch from the hardcoded ARM64_TRAY_URL, so publishing from a
# different repo would gate an asset stream nobody downloads and silently
# decouple the sha pin from the bytes Apple Silicon users execute.
URL_REPO=$(node -e '
const m = require("fs").readFileSync("cli/hooks/trayRuntime.js", "utf8")
.match(/"https:\/\/github\.com\/([^\/"]+\/[^\/"]+)\/releases\/download\/tray-binaries\/tray_darwin_arm64"/);
if (!m) { console.error("::error::ARM64_TRAY_URL not found in cli/hooks/trayRuntime.js"); process.exit(1); }
process.stdout.write(m[1]);
')
if [ "${{ github.repository }}" != "$URL_REPO" ]; then
echo "::error::publishing to ${{ github.repository }}, but ARM64_TRAY_URL points clients at $URL_REPO"
echo "Repoint ARM64_TRAY_URL in cli/hooks/trayRuntime.js at this repo, or run the publish from $URL_REPO."
exit 1
fi
# gh release upload does not create the release, so bootstrap it on the
# first publish run rather than failing with "release not found".
if ! gh release view tray-binaries >/dev/null 2>&1; then
echo "Release 'tray-binaries' does not exist yet — creating it"
gh release create tray-binaries --latest=false \
--title "Native macOS tray binaries" \
--notes "Built by .github/workflows/tray-binaries.yml. Provenance and the pinned sha256 live in that workflow and in cli/hooks/trayRuntime.js (ARM64_TRAY_SHA256)."
fi
gh release upload tray-binaries cli/.tray-build/tray_darwin_arm64 --clobber
echo "Uploaded. Verifying public download URL..."
URL="https://github.com/${{ github.repository }}/releases/download/tray-binaries/tray_darwin_arm64"
GOT=""
for attempt in 1 2 3; do
if curl -fsSL --max-time 60 -o /tmp/verify "$URL"; then
GOT=$(shasum -a 256 /tmp/verify | cut -d' ' -f1)
if [ "$GOT" = "${{ steps.sha.outputs.built }}" ]; then break; fi
fi
# A just-uploaded asset can 404 or serve stale bytes until the CDN catches up.
echo "attempt $attempt: got '${GOT:-<download failed>}' — retrying in 15s"
sleep 15
done
if [ "$GOT" != "${{ steps.sha.outputs.built }}" ]; then
echo "::error::downloaded asset sha256 '${GOT:-<none>}' != built ${{ steps.sha.outputs.built }} after 3 attempts"
exit 1
fi
echo "✅ $URL serves the expected bytes"

View File

@@ -1,3 +1,33 @@
# v0.5.91 (2026-09-26)
## Features
- **Providers**: add Token Harbor provider and four OpenAI-compatible aggregator providers (dahl, atria, agnes, bai)
- **Claude**: forward `x-claude-code-session-id` on OAuth requests; merge client `anthropic-beta` flags and forward rate-limit headers; return thinking text to OpenAI-format clients
- **Codex**: add GPT-6 Sol and Luna support
- **CLI Tools**: support multiple model profiles for Codex CLI
- **Hermes**: multi-role model config (delegation + auxiliary slots)
- **OpenCode Go**: complete the Go catalog (40 models) with auto-fetch + family endpoint regex
- **Usage**: show and redeem free limit resets for cc accounts
- **Cline**: expose the `cline-free/*` tier and price it at zero
- **Combos**: display vision adapter models in an ordered table view
## Fixes
- **Claude**: decloak tool names when `toolNameMap` misses (#4342); update spoofed cli version to 2.1.280 to support Opus 5.5
- **Providers API**: make POST `/api/providers` O(1) and refuse silent key overwrite (#4350)
- **Capabilities**: stop caching the catalog source per module copy (#4351)
- **OAuth**: stop Zed paste-token crash and add IDE auto-import (#4359)
- **Dashboard**: resolve combo limits with the server's capabilities (#4360); lazy-load charts and `marked`, preload in background on idle
- **Responses**: carry the streamed output items in `response.completed` (#4307)
- **STT**: dispatch live-API-only Gemini models over the Live WebSocket transport (#4006)
- **Gemini**: guard terminal model turns and unresponded functionCalls in `normalizeGeminiContents`
- **Command Code**: replay raw byte chunks to preserve all NDJSON lines
- **Translator**: stop emitting empty `<think>` markers into OpenAI content
- **CLI Tools**: refresh Codex settings after apply (#4347); keep existing `ANTHROPIC_AUTH_TOKEN` when applying Claude settings
- **Tray**: native arm64 macOS menubar binary, no Rosetta required
- **CLI**: filter model selector by active connections and noAuth providers
- **Usage**: key live byApiKey stats by full api key to prevent team-key collision and preserve API key usage attribution
- **Tailscale**: cap enable-flow health wait at 20s
# v0.5.86 (2026-09-23) # v0.5.86 (2026-09-23)
## Features ## Features

View File

@@ -88,6 +88,7 @@ Pre-translate hooks that compress `tool_result` content in-place to cut tokens.
- `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code. - `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code.
- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix. - Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix.
- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator. - Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator.
- **Security-first on PRs**: Security is the top priority when reviewing or creating PRs. Audit authentication, credential/token storage & leaks, header manipulation (`X-Forwarded-For`), and SSRF risks before functional logic. Always include explicit security warnings/notes when reporting PR reviews or changes to the user.
- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`). - Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`).
<!-- BEGIN:nextjs-agent-rules --> <!-- BEGIN:nextjs-agent-rules -->

1
cli/.gitignore vendored
View File

@@ -1,2 +1,3 @@
app/* app/*
node_modules/* node_modules/*
.tray-build/

View File

@@ -5,9 +5,17 @@
// //
// We use the maintained `systray2` fork. The original `systray@1.0.5` package // We use the maintained `systray2` fork. The original `systray@1.0.5` package
// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern // bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern
// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. // dyld (macOS 14+), so it fails to load at all.
//
// Note that systray2 is NOT an Apple Silicon fix: like its predecessor it ships
// only an x86_64 `tray_darwin_release`, and picks it by process.platform with no
// process.arch branch, so there is no native slice to select. On arm64 macOS the
// tray therefore needs Rosetta 2 and dies with EBADARCH without it. We overlay
// our own arm64 build of the same upstream source on top — see ensureArm64TrayBin.
const { spawnSync } = require("child_process"); const { spawnSync } = require("child_process");
const crypto = require("crypto");
const fs = require("fs"); const fs = require("fs");
const os = require("os");
const path = require("path"); const path = require("path");
const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime"); const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime");
@@ -15,6 +23,19 @@ const SYSTRAY_PKG = "systray2";
const SYSTRAY_VERSION = "2.1.4"; const SYSTRAY_VERSION = "2.1.4";
const LEGACY_SYSTRAY_PKG = "systray"; const LEGACY_SYSTRAY_PKG = "systray";
// Pinned `tray-binaries` release rather than `latest`, so the URL is stable and
// the artifact can only change by a deliberate re-upload. The workflow's publish
// step re-derives this repo from the literal below and refuses to upload
// anywhere else, so the integrity gate can't drift from what clients fetch.
//
// The asset is built by .github/workflows/tray-binaries.yml on a macos-15 runner.
// cgo compiles AppKit against the runner's SDK, so this value tracks that image:
// when GitHub updates it the sha changes, the workflow refuses to publish, and
// this constant must be bumped in the same change as the re-upload.
const ARM64_TRAY_URL = "https://github.com/decolua/9router/releases/download/tray-binaries/tray_darwin_arm64";
const ARM64_TRAY_SHA256 = "487e3c365aaa1eb6ad295bf3989711e975b52cee07505bf641c8559954881c81";
const ARM64_RETRY_COOLDOWN_MS = 24 * 60 * 60 * 1000;
function hasSystray() { function hasSystray() {
return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json"));
} }
@@ -71,6 +92,124 @@ function ensureRuntimeDir() {
return dir; return dir;
} }
// A thin (non-fat) 64-bit Mach-O stores its magic then cputype, both LE.
// CPU_TYPE_ARM64 is CPU_TYPE_ARM | CPU_ARCH_ABI64. Fat/universal binaries use a
// different magic and are reported as "not arm64" here, which is fine: we only
// ever overlay a thin arm64 build and only need to tell it apart from x86_64.
function isArm64MachO(file) {
let fd = null;
try {
fd = fs.openSync(file, "r");
const buf = Buffer.alloc(8);
fs.readSync(fd, buf, 0, 8, 0);
if (buf.readUInt32LE(0) !== 0xfeedfacf) return false;
return buf.readUInt32LE(4) === 0x0100000c;
} catch {
return false;
} finally {
if (fd !== null) try { fs.closeSync(fd); } catch {}
}
}
// systray2 is constructed with copyDir:true, so what actually executes is
// ~/.cache/node-systray/<version>/tray_darwin_release, and index.js only re-copies
// when that path is absent. An overlaid binary stays invisible until this is cleared.
// Scoped to our systray2 version: the parent dir is machine-global and shared
// with any other node-systray consumer.
function bustSystrayCopyCache() {
try {
fs.rmSync(path.join(os.homedir(), ".cache", "node-systray", SYSTRAY_VERSION), { recursive: true, force: true });
} catch {}
}
function arm64AttemptMarker() {
return path.join(getRuntimeDir(), ".tray-arm64-attempt");
}
// ensureTrayRuntime runs synchronously on every `9router` start (cli.js), so a
// failed download must not re-block the next launch. Retry at most daily.
function recentlyAttemptedArm64() {
try {
const at = Number(fs.readFileSync(arm64AttemptMarker(), "utf8").trim());
return Number.isFinite(at) && Date.now() - at < ARM64_RETRY_COOLDOWN_MS;
} catch {
return false;
}
}
function markArm64Attempt() {
try { fs.writeFileSync(arm64AttemptMarker(), String(Date.now())); } catch {}
}
// Cleared on success so the cooldown only ever throttles *failures*. Without
// this, anything that restores systray2's x86_64 binary later — notably a
// globally installed 9router older than this change, which shares the same
// ~/.9router/runtime — would leave the user waiting out the cooldown.
function clearArm64Attempt() {
try { fs.rmSync(arm64AttemptMarker(), { force: true }); } catch {}
}
// Throws on any failure so the caller has a single error path.
function downloadFile(url, dest, timeoutSec) {
// darwin-only path, and curl ships with macOS, so this needs no extra dep and
// keeps the caller synchronous.
const res = spawnSync("curl", ["-fsSL", "--max-time", String(timeoutSec), "-o", dest, url], {
encoding: "utf8",
timeout: (timeoutSec + 5) * 1000
});
if (res.status === 0 && fs.existsSync(dest)) return;
const detail = (res.stderr || res.error?.message || `curl exit ${res.status}`).trim().split("\n").pop();
throw new Error(detail || "download failed");
}
function sha256File(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
// Replace systray2's x86_64 macOS binary with a native arm64 build so Apple
// Silicon users get a tray without installing Rosetta 2. Any failure leaves the
// Intel binary untouched, which still works under Rosetta.
//
// Takes no `silent` flag on purpose: cli.js calls ensureTrayRuntime({silent:true})
// synchronously on every start, and a stalled curl would otherwise freeze the
// launch for up to 30s with no output at all. These lines print at most once per
// 24h on failure and once ever on success, so they are worth more than the quiet.
function ensureArm64TrayBin() {
if (process.platform !== "darwin" || process.arch !== "arm64") return { skipped: true };
const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", "tray_darwin_release");
if (!fs.existsSync(binPath)) return { skipped: true };
if (isArm64MachO(binPath)) return { native: true };
if (recentlyAttemptedArm64()) return { deferred: true };
markArm64Attempt();
console.log("⏳ Downloading native Apple Silicon tray binary...");
// pid-scoped: two concurrent starts (postinstall racing cli.js, or two
// terminals) would otherwise interleave writes to one file, fail each other's
// checksum, and delete each other's in-flight download from the catch below.
const tmp = `${binPath}.arm64.${process.pid}.tmp`;
try {
downloadFile(ARM64_TRAY_URL, tmp, 30);
const sum = sha256File(tmp);
// Integrity matters more than usual: this is an executable that runs on
// every Apple Silicon user's machine.
if (sum !== ARM64_TRAY_SHA256) throw new Error(`checksum mismatch (got ${sum.slice(0, 12)}…)`);
if (!isArm64MachO(tmp)) throw new Error("downloaded file is not an arm64 Mach-O");
fs.chmodSync(tmp, 0o755);
fs.renameSync(tmp, binPath);
bustSystrayCopyCache();
clearArm64Attempt();
console.log("✅ Native Apple Silicon tray installed");
return { native: true, installed: true };
} catch (e) {
try { fs.rmSync(tmp, { force: true }); } catch {}
console.warn("⚠️ Native tray download failed — falling back to the Intel binary");
console.warn(` Reason: ${e.message}`);
console.warn(" The Intel tray needs Rosetta 2: softwareupdate --install-rosetta --agree-to-license");
return { native: false, error: e.message };
}
}
function npmInstall(pkgs, { silent = false } = {}) { function npmInstall(pkgs, { silent = false } = {}) {
const cwd = ensureRuntimeDir(); const cwd = ensureRuntimeDir();
if (!silent) console.log("⏳ Installing system tray (first run)..."); if (!silent) console.log("⏳ Installing system tray (first run)...");
@@ -94,14 +233,20 @@ function ensureTrayRuntime({ silent = false } = {}) {
if (process.platform === "win32") { if (process.platform === "win32") {
return { systray: false, skipped: true }; return { systray: false, skipped: true };
} }
if (hasSystray()) {
let ready = hasSystray();
if (!ready) {
ready = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }) && hasSystray();
}
if (ready) {
chmodSystrayBin({ silent }); chmodSystrayBin({ silent });
if (!silent) console.log("✅ System tray ready"); if (!silent) console.log("✅ System tray ready");
return { systray: true };
} }
const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent });
if (ok) chmodSystrayBin({ silent }); // Runs after the ready log so a download failure doesn't read as a broken
return { systray: ok && hasSystray() }; // tray — the Intel binary still works under Rosetta.
const arm64 = ready ? ensureArm64TrayBin() : { skipped: true };
return { systray: ready, arm64 };
} }
module.exports = { ensureTrayRuntime }; module.exports = { ensureTrayRuntime, ensureArm64TrayBin };

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router", "name": "9router",
"version": "0.5.86", "version": "0.5.91",
"description": "9Router CLI - Start and manage 9Router server", "description": "9Router CLI - Start and manage 9Router server",
"bin": { "bin": {
"9router": "./cli.js" "9router": "./cli.js"
@@ -16,6 +16,7 @@
"scripts": { "scripts": {
"dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js", "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js",
"build": "node scripts/build-cli.js", "build": "node scripts/build-cli.js",
"build:tray-arm64": "node scripts/buildTrayArm64.js",
"pack:cli": "npm run build && npm pack --pack-destination ..", "pack:cli": "npm run build && npm pack --pack-destination ..",
"publish:cli": "npm run build && npm publish", "publish:cli": "npm run build && npm publish",
"postinstall": "node hooks/postinstall.js", "postinstall": "node hooks/postinstall.js",
@@ -29,7 +30,8 @@
"react-dom": "19.2.1" "react-dom": "19.2.1"
}, },
"comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.",
"comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails to load on modern macOS dyld. Neither package ships an arm64 macOS binary, so on Apple Silicon hooks/trayRuntime.js overlays our own arm64 build from the tray-binaries GitHub release; without it the tray requires Rosetta 2.",
"comment_tray_arm64": "tray_darwin_arm64 is built by scripts/buildTrayArm64.js (npm run build:tray-arm64) from felixhao28/systray-portable — the same source systray2's binary comes from — and uploaded to the pinned 'tray-binaries' GitHub release. Its sha256 is pinned as ARM64_TRAY_SHA256 in hooks/trayRuntime.js and verified after every download; rebuild and update both together. .github/workflows/tray-binaries.yml builds the same artifact in CI but refuses to publish when the sha diverges from the pin.",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env node
// Rebuilds tray_darwin_arm64, the native Apple Silicon menubar binary that
// hooks/trayRuntime.js overlays on top of systray2's x86_64-only build.
//
// Must run on macOS: getlantern/systray is cgo against AppKit, so the arm64
// slice needs a real macOS SDK. Requires Go on PATH (`mise use -g go@latest`).
//
// Output is NOT reproducible across Go versions even with -s -w, so after a
// rebuild you must re-upload the asset and update ARM64_TRAY_SHA256 in
// hooks/trayRuntime.js — this script prints both and fails if they diverge.
const { execFileSync, spawnSync } = require("child_process");
const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
const UPSTREAM_REPO = "https://github.com/felixhao28/systray-portable.git";
// master as of 2021-09-15, the commit systray2@2.1.4's own binary was built from.
const UPSTREAM_COMMIT = "6eddc917bf39fcc0d95b57a0741d0c065fbd1e23";
const outDir = path.join(__dirname, "..", ".tray-build");
const outFile = path.join(outDir, "tray_darwin_arm64");
const trayRuntimePath = path.join(__dirname, "..", "hooks", "trayRuntime.js");
function fail(msg) {
console.error(`\n❌ ${msg}`);
process.exit(1);
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, { stdio: "inherit", ...opts });
if (res.status !== 0) fail(`${cmd} ${args.join(" ")} exited with ${res.status}`);
}
if (process.platform !== "darwin") fail("must be run on macOS (cgo needs the AppKit SDK)");
const go = spawnSync("go", ["version"], { encoding: "utf8" });
if (go.status !== 0) {
fail("Go toolchain not found on PATH. Install it with: mise use -g go@latest");
}
console.log(`Go: ${go.stdout.trim()}`);
const srcDir = fs.mkdtempSync(path.join(os.tmpdir(), "systray-portable-"));
// fail() exits through process.exit(), which does not unwind the stack, so a
// try/finally here would leak the clone on every failed build. An exit handler
// covers normal completion, fail(), and uncaught exceptions alike.
process.on("exit", () => {
try { fs.rmSync(srcDir, { recursive: true, force: true }); } catch {}
});
console.log(`\nCloning ${UPSTREAM_REPO} @ ${UPSTREAM_COMMIT.slice(0, 7)}`);
run("git", ["clone", "--quiet", UPSTREAM_REPO, srcDir]);
run("git", ["-C", srcDir, "checkout", "--quiet", UPSTREAM_COMMIT]);
const head = execFileSync("git", ["-C", srcDir, "log", "-1", "--format=%H %ad %s", "--date=short"], {
encoding: "utf8"
}).trim();
console.log(`HEAD: ${head}`);
run("go", ["mod", "download"], { cwd: srcDir });
console.log("\nBuilding darwin/arm64...");
// -trimpath strips the local build directory from the binary, so two builds
// from the same commit + Go version hash identically regardless of where they
// ran. Without it the pinned sha256 could never be regenerated.
run("go", ["build", "-trimpath", "-ldflags", "-s -w", "-o", outFile, "tray.go"], {
cwd: srcDir,
env: { ...process.env, CGO_ENABLED: "1", GOOS: "darwin", GOARCH: "arm64" }
});
// ── Verify ────────────────────────────────────────────────────────────────
const buf = Buffer.alloc(8);
const fd = fs.openSync(outFile, "r");
fs.readSync(fd, buf, 0, 8, 0);
fs.closeSync(fd);
if (buf.readUInt32LE(0) !== 0xfeedfacf || buf.readUInt32LE(4) !== 0x0100000c) {
fail("output is not a thin arm64 Mach-O");
}
// Apple Silicon refuses to execute an unsigned binary. Go's linker applies an
// ad-hoc signature automatically; confirm it survived.
const sig = spawnSync("codesign", ["--verify", outFile], { encoding: "utf8" });
if (sig.status !== 0) fail(`ad-hoc signature invalid: ${(sig.stderr || "").trim()}`);
const sha256 = crypto.createHash("sha256").update(fs.readFileSync(outFile)).digest("hex");
const sizeMb = (fs.statSync(outFile).size / 1024 / 1024).toFixed(2);
console.log(`\n✅ ${outFile}`);
console.log(` arch: arm64 (ad-hoc signed, verified)`);
console.log(` size: ${sizeMb} MB`);
console.log(` sha256: ${sha256}`);
// Whitespace-tolerant: a formatter could wrap the assignment across lines, and
// a null match must fail loudly rather than silently read as "no pin".
const pinMatch = fs.readFileSync(trayRuntimePath, "utf8").match(/ARM64_TRAY_SHA256\s*=\s*"([0-9a-f]{64})"/);
if (!pinMatch) fail(`could not find ARM64_TRAY_SHA256 in ${trayRuntimePath}`);
const pinned = pinMatch[1];
if (pinned === sha256) {
console.log(`\n Matches ARM64_TRAY_SHA256 in hooks/trayRuntime.js — no code change needed.`);
} else {
console.log(`\n⚠️ Differs from ARM64_TRAY_SHA256 in hooks/trayRuntime.js (${pinned}).`);
console.log(` Re-upload the release asset, then update that constant to the sha256 above.`);
}
console.log(`\nNext: upload to the pinned release tag, keeping the asset name stable:`);
console.log(` gh release upload tray-binaries "${outFile}" --clobber`);

View File

@@ -141,11 +141,14 @@ function initWindowsTray(options) {
/** /**
* macOS/Linux tray via systray binary * macOS/Linux tray via systray binary
* *
* Prefers `systray2` (active fork of `systray`, ships newer * Prefers `systray2`, the active fork of `systray`. Both ship only an x86_64
* getlantern/systray-portable binaries that work on macOS 14+ and Apple * `tray_darwin_release` and select it by process.platform alone, so on Apple
* Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 * Silicon the tray runs under Rosetta 2 and fails with EBADARCH when Rosetta is
* is not available, though that binary's Mach-O headers are rejected by * absent. hooks/trayRuntime.js overlays a native arm64 build over that file to
* modern dyld and the icon will not appear. * avoid the dependency; the fallbacks below are Intel-only.
*
* Falls back to legacy `systray@1.0.5` if systray2 is unavailable, though that
* binary's Mach-O headers are rejected by modern dyld and no icon will appear.
*/ */
function resolveSystray() { function resolveSystray() {
let runtimeDir = null; let runtimeDir = null;

View File

@@ -2,22 +2,24 @@ const api = require("../api/client");
const { prompt } = require("./input"); const { prompt } = require("./input");
const { clearScreen } = require("./display"); const { clearScreen } = require("./display");
// Provider alias order: OAuth first, then API Key (matches ModelSelectModal) // Provider alias order: OAuth first, then Free, then API Key
const PROVIDER_ALIAS_ORDER = [ const PROVIDER_ALIAS_ORDER = [
"cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", "oc",
"openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini" "openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini"
]; ];
// Alias to display name mapping // Alias to display name mapping
const PROVIDER_ALIAS_NAMES = { const PROVIDER_ALIAS_NAMES = {
cc: "Claude Code", cc: "Claude Code",
ag: "Antigravity", ag: "Antigravity",
cx: "OpenAI Codex", cx: "OpenAI Codex",
if: "iFlow AI", if: "iFlow AI",
qw: "Qwen Code", qw: "Qwen Code",
gc: "Gemini CLI", gc: "Gemini CLI",
gh: "GitHub Copilot", gh: "GitHub Copilot",
kr: "Kiro AI", kr: "Kiro AI",
oc: "OpenCode Free",
opencode: "OpenCode Free",
openrouter: "OpenRouter", openrouter: "OpenRouter",
glm: "GLM Coding", glm: "GLM Coding",
kimi: "Kimi Coding", kimi: "Kimi Coding",
@@ -27,30 +29,78 @@ const PROVIDER_ALIAS_NAMES = {
gemini: "Gemini" gemini: "Gemini"
}; };
const PROVIDER_ID_TO_ALIAS = {
claude: "cc",
codex: "cx",
"gemini-cli": "gc",
github: "gh",
antigravity: "ag",
iflow: "if",
qwen: "qw",
kiro: "kr",
cursor: "cu",
cline: "cline",
clinepass: "clinepass",
qoder: "qd",
"qoder-cn": "qd",
gitlab: "gitlab",
"codebuddy-cn": "cb",
"codebuddy-intl": "cbai",
kimchi: "kimchi",
"grok-cli": "grok-cli",
trae: "trae",
windsurf: "windsurf",
zed: "zed",
opencode: "oc",
"opencode-go": "ocg",
"opencode-zen": "ocz",
};
// Providers usable without stored credentials
const NO_AUTH_PROVIDERS = new Set(["opencode", "oc"]);
/** /**
* Get all available models grouped by provider + combos * Get all available models grouped by provider + combos (filtered by active connections)
* @returns {Promise<{combos: Array, groups: Object}>} * @returns {Promise<{combos: Array, groups: Object}>}
*/ */
async function getAvailableModelsGrouped() { async function getAvailableModelsGrouped() {
const result = await api.getAvailableModels(); const [modelsResult, providersResult] = await Promise.all([
if (!result.success) return { combos: [], groups: {} }; api.getAvailableModels(),
api.getProviders()
const models = result.data?.data || []; ]);
if (!modelsResult.success) return { combos: [], groups: {} };
const connections = providersResult.success ? (providersResult.data?.connections || []) : [];
const activeAliases = new Set(NO_AUTH_PROVIDERS);
connections.forEach(conn => {
if (conn.isActive === false) return;
const p = conn.provider;
if (!p) return;
activeAliases.add(p);
const alias = conn.providerSpecificData?.prefix || PROVIDER_ID_TO_ALIAS[p] || p;
activeAliases.add(alias);
});
const models = modelsResult.data?.data || [];
const combos = []; const combos = [];
const groups = {}; const groups = {};
models.forEach(m => { models.forEach(m => {
if (m.owned_by === "combo") { if (m.owned_by === "combo") {
combos.push(m.id); combos.push(m.id);
} else { } else {
const provider = m.owned_by; const provider = m.owned_by;
// Only keep connected providers or noAuth providers
if (!activeAliases.has(provider)) return;
if (!groups[provider]) { if (!groups[provider]) {
groups[provider] = []; groups[provider] = [];
} }
groups[provider].push(m.id); groups[provider].push(m.id);
} }
}); });
return { combos, groups }; return { combos, groups };
} }
@@ -68,6 +118,19 @@ async function selectModelFromList(title, currentValue = "", options = {}) {
const totalModels = combos.length + Object.values(groups).flat().length; const totalModels = combos.length + Object.values(groups).flat().length;
if (totalModels === 0) { if (totalModels === 0) {
clearScreen();
console.log(`\n🎯 ${title}`);
console.log("=".repeat(50));
console.log("\n No connected providers found.");
console.log(" Please connect a provider in Providers menu first.\n");
console.log(" m. ✍️ Enter custom model ID");
console.log(" 0. Cancel\n");
const act = await prompt("Select option (m/0): ");
const trimmed = act.trim();
if (trimmed.toLowerCase() === "m") {
const custom = await prompt("Enter custom model ID: ");
return custom.trim() || null;
}
return null; return null;
} }

View File

@@ -178,7 +178,7 @@ export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official C
// makes the backend flag the request and answer 429 Quota Exhausted. // makes the backend flag the request and answer 429 Quota Exhausted.
export const ANTIGRAVITY_PROMPT_REWRITES = [ export const ANTIGRAVITY_PROMPT_REWRITES = [
{ from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" }, { from: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", to: "" },
{ from: /You are Hermes Agent,\s*(an intelligent AI assistant)(?: created by Nous Research)?\./gi, to: "You are Hermes Agent. You are $1." }, { from: /You are Hermes(?: Agent)?(?:,\s*(?:an intelligent AI assistant|an AI assistant|an AI agent))?(?:,?\s*(?:built|created)\s+by\s+Nous Research)?\./gi, to: "You are an AI assistant." },
// Claude Code prepends this line to its system prompt. The Claude-format translator strips it, // Claude Code prepends this line to its system prompt. The Claude-format translator strips it,
// but OpenAI-format clients (e.g. proxies that convert Claude Code to /v1/chat/completions) // but OpenAI-format clients (e.g. proxies that convert Claude Code to /v1/chat/completions)
// pass it through, and any system text containing it gets a fake 429 RESOURCE_EXHAUSTED. // pass it through, and any system text containing it gets a fake 429 RESOURCE_EXHAUSTED.

View File

@@ -3,10 +3,13 @@ import REGISTRY from "../providers/registry/index.js";
// PROVIDER_MODELS now built from providers/registry (transport + models co-located) // PROVIDER_MODELS now built from providers/registry (transport + models co-located)
import { PROVIDER_MODELS } from "../providers/index.js"; import { PROVIDER_MODELS } from "../providers/index.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX, isMuseSparkModel } from "../providers/models/helpers.js"; import { CODEX_REVIEW_SUFFIX, isMuseSparkModel, opencodeFamilyFormats } from "../providers/models/helpers.js";
import { FORMATS } from "../translator/formats.js"; import { FORMATS } from "../translator/formats.js";
export { PROVIDER_MODELS }; export { PROVIDER_MODELS };
// OpenCode providers sharing the endpoint-family fallback for unknown model ids
const isOpenCodeAlias = (aliasOrId) => !aliasOrId || ["oc", "opencode", "ocg", "opencode-go", "ocz", "opencode-zen"].includes(aliasOrId);
// Helper functions // Helper functions
export function getProviderModels(aliasOrId) { export function getProviderModels(aliasOrId) {
@@ -53,20 +56,29 @@ export function findModelName(aliasOrId, modelId) {
} }
export function getModelTargetFormat(aliasOrId, modelId) { export function getModelTargetFormat(aliasOrId, modelId) {
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go" || aliasOrId === "ocz" || aliasOrId === "opencode-zen") && isMuseSparkModel(modelId)) { if (isOpenCodeAlias(aliasOrId) && isMuseSparkModel(modelId)) {
return FORMATS.OPENAI_RESPONSES; return FORMATS.OPENAI_RESPONSES;
} }
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null; if (!models) return null;
return modelTargetFormat(findModel(models, modelId, aliasOrId)); const found = findModel(models, modelId, aliasOrId);
if (found) return modelTargetFormat(found);
// Family fallback keeps modelsFetcher/passthrough ids on their endpoint lane
if (isOpenCodeAlias(aliasOrId)) return opencodeFamilyFormats(modelId)?.targetFormat || null;
return null;
} }
// Declared upstream formats for a model (registry `supportedFormats`). Drives the // Declared upstream formats for a model (registry `supportedFormats`). Drives the
// per-model guard on the sourceFormat-matched transport; null when undeclared. // per-model guard on the sourceFormat-matched transport; null when undeclared.
// Unknown OpenCode ids fall back to the family regex (chat lane by default) so
// auto-fetched models never wrongly use the sourceFormat-matched transport.
export function getModelSupportedFormats(aliasOrId, modelId) { export function getModelSupportedFormats(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null; if (!models) return null;
return modelSupportedFormats(findModel(models, modelId, aliasOrId)); const found = findModel(models, modelId, aliasOrId);
if (found) return modelSupportedFormats(found);
if (isOpenCodeAlias(aliasOrId)) return opencodeFamilyFormats(modelId)?.supportedFormats || [FORMATS.OPENAI];
return null;
} }
export function getModelType(aliasOrId, modelId) { export function getModelType(aliasOrId, modelId) {

View File

@@ -7,7 +7,7 @@ import {
} from "../services/oauthCredentialManager.js"; } from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/concerns/image.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js"; import { getModelUpstreamId, getProviderModels } from "../config/providerModels.js";
import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js";
import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js"; import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js";
import { dbg } from "../utils/debugLog.js"; import { dbg } from "../utils/debugLog.js";
@@ -25,6 +25,10 @@ const CODEX_SSE_USER_OUTPUT_PATTERNS = [
]; ];
const CODEX_SSE_PEEK_BYTES = 256 * 1024; const CODEX_SSE_PEEK_BYTES = 256 * 1024;
const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model."; const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model.";
function isCodexResponsesLiteModel(model) {
const baseId = String(model || "").replace(/\([^()]+\)\s*$/, "");
return getProviderModels("cx").some((entry) => entry.id === baseId && entry.responsesLite === true);
}
// Server-generated item id prefixes that Codex /responses cannot resolve when store=false // Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
@@ -43,7 +47,7 @@ const CODEX_PASSTHROUGH_TOOL_TYPES = new Set(["custom"]);
const RESPONSES_API_ALLOWLIST = new Set([ const RESPONSES_API_ALLOWLIST = new Set([
"model", "input", "instructions", "tools", "tool_choice", "stream", "store", "model", "input", "instructions", "tools", "tool_choice", "stream", "store",
"reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata", "reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata",
"text" "text", "parallel_tool_calls"
]); ]);
// Convert role=system → role=developer in body.input (keeps content in cacheable prefix) // Convert role=system → role=developer in body.input (keeps content in cacheable prefix)
@@ -57,13 +61,14 @@ function convertSystemToDeveloperRole(body) {
} }
// Strip server-generated item IDs (rs_/fc_/resp_/msg_) from input — avoids 404 with store=false // Strip server-generated item IDs (rs_/fc_/resp_/msg_) from input — avoids 404 with store=false
function stripStoredItemReferences(body) { function stripStoredItemReferences(body, preserveLitePrefix = false) {
if (!Array.isArray(body.input)) return; if (!Array.isArray(body.input)) return;
body.input = body.input.filter((item) => { body.input = body.input.filter((item) => {
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false;
if (item && typeof item === "object" && !Array.isArray(item)) { if (item && typeof item === "object" && !Array.isArray(item)) {
if (item.type === "item_reference") return false; if (item.type === "item_reference") return false;
if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id; if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)
&& !(preserveLitePrefix && item.role === "developer" && item.id.startsWith("msg_"))) delete item.id;
} }
return true; return true;
}); });
@@ -138,6 +143,7 @@ function resolveCacheSessionId(body, credentials) {
function normalizeReasoningEffort(model, value) { function normalizeReasoningEffort(model, value) {
const supportedLevels = getThinkingLevels("codex", model); const supportedLevels = getThinkingLevels("codex", model);
if (supportedLevels?.includes(value)) return value; if (supportedLevels?.includes(value)) return value;
if (isCodexResponsesLiteModel(model) && (value === "none" || value === "minimal")) return "low";
if (value === "ultra" && supportedLevels?.includes("max")) return "max"; if (value === "ultra" && supportedLevels?.includes("max")) return "max";
if (value === "max" || value === "ultra") return "xhigh"; if (value === "max" || value === "ultra") return "xhigh";
return value; return value;
@@ -209,8 +215,11 @@ export class CodexExecutor extends BaseExecutor {
* Override headers to add codex-specific identity headers. * Override headers to add codex-specific identity headers.
* transformRequest runs BEFORE buildHeaders, sets this._currentSessionId. * transformRequest runs BEFORE buildHeaders, sets this._currentSessionId.
*/ */
buildHeaders(credentials, stream = true) { buildHeaders(credentials, stream = true, _url = null, model = null) {
const headers = super.buildHeaders(credentials, stream); const headers = super.buildHeaders(credentials, stream);
if (isCodexResponsesLiteModel(model && getModelUpstreamId("cx", model))) {
headers["x-openai-internal-codex-responses-lite"] = "true";
}
headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default"; headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default";
// Identify client type to Codex backend (matches official codex CLI) // Identify client type to Codex backend (matches official codex CLI)
if (!headers["originator"]) headers["originator"] = "codex_cli_rs"; if (!headers["originator"]) headers["originator"] = "codex_cli_rs";
@@ -408,6 +417,8 @@ export class CodexExecutor extends BaseExecutor {
// Convert string input to array format (Codex API requires input as array) // Convert string input to array format (Codex API requires input as array)
const normalized = normalizeResponsesInput(body.input); const normalized = normalizeResponsesInput(body.input);
if (normalized) body.input = normalized; if (normalized) body.input = normalized;
const upstreamModel = getModelUpstreamId("cx", body.model || model);
const responsesLite = isCodexResponsesLiteModel(upstreamModel);
// Ensure input is present and non-empty (Codex API rejects empty input) // Ensure input is present and non-empty (Codex API rejects empty input)
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
@@ -417,7 +428,7 @@ export class CodexExecutor extends BaseExecutor {
// Keep system prompts in body.input as role=developer so they stay in the cacheable prefix // Keep system prompts in body.input as role=developer so they stay in the cacheable prefix
convertSystemToDeveloperRole(body); convertSystemToDeveloperRole(body);
// Strip server-generated item IDs (rs_/fc_/resp_/msg_) — Codex /responses can't resolve when store=false // Strip server-generated item IDs (rs_/fc_/resp_/msg_) — Codex /responses can't resolve when store=false
stripStoredItemReferences(body); stripStoredItemReferences(body, responsesLite);
// Flatten function tools + drop unsupported types // Flatten function tools + drop unsupported types
normalizeCodexTools(body); normalizeCodexTools(body);
@@ -425,7 +436,7 @@ export class CodexExecutor extends BaseExecutor {
body.stream = true; body.stream = true;
// If no instructions provided, inject default Codex instructions // If no instructions provided, inject default Codex instructions
if (!body.instructions || body.instructions.trim() === "") { if (!responsesLite && (!body.instructions || body.instructions.trim() === "")) {
body.instructions = CODEX_DEFAULT_INSTRUCTIONS; body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
} }
@@ -438,7 +449,29 @@ export class CodexExecutor extends BaseExecutor {
} }
// Map virtual Codex review models to the upstream Codex model before suffix parsing. // Map virtual Codex review models to the upstream Codex model before suffix parsing.
body.model = getModelUpstreamId("cx", body.model || model); body.model = upstreamModel;
if (responsesLite) {
// Codex 0.155 carries tools and instructions as input prefix items.
const input = Array.isArray(body.input) ? body.input : [body.input];
const hasLitePrefix = input.some((item) => item?.type === "additional_tools");
if (!hasLitePrefix) {
const instructions = typeof body.instructions === "string" && body.instructions.trim()
? body.instructions : CODEX_DEFAULT_INSTRUCTIONS;
const prefix = [{ type: "additional_tools", role: "developer", tools: Array.isArray(body.tools) ? body.tools : [] }];
if (instructions) {
prefix.push({ type: "message", role: "developer", content: [{ type: "input_text", text: instructions }] });
}
input.unshift(...prefix);
}
body.input = input;
body.instructions = "";
body.tools = null;
body.tool_choice ||= "auto";
body.parallel_tool_calls = false;
} else {
delete body.parallel_tool_calls;
}
// Extract thinking level from model name suffix // Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
@@ -455,12 +488,13 @@ export class CodexExecutor extends BaseExecutor {
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) { if (!body.reasoning) {
const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low'); const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || (responsesLite ? 'medium' : 'low'));
body.reasoning = { effort, summary: "auto" }; body.reasoning = responsesLite ? { effort } : { effort, summary: "auto" };
} else { } else {
body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort); body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort);
if (!body.reasoning.summary) body.reasoning.summary = "auto"; if (!responsesLite && !body.reasoning.summary) body.reasoning.summary = "auto";
} }
if (responsesLite) body.reasoning.context = "all_turns";
delete body.reasoning_effort; delete body.reasoning_effort;
// Include reasoning encrypted content (required by Codex backend for reasoning models) // Include reasoning encrypted content (required by Codex backend for reasoning models)

View File

@@ -148,7 +148,7 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model)
const reader = originalResponse.body.getReader(); const reader = originalResponse.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ""; let buffer = "";
const bufferedLines = []; const rawChunks = [];
let detectedError = null; let detectedError = null;
try { try {
@@ -162,16 +162,15 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model)
const parsed = JSON.parse(jsonStr); const parsed = JSON.parse(jsonStr);
if (parsed?.type === "error") { if (parsed?.type === "error") {
detectedError = parsed; detectedError = parsed;
} else {
bufferedLines.push(trimmed);
} }
} catch { } catch {
bufferedLines.push(trimmed); /* ignore */
} }
} }
break; break;
} }
rawChunks.push(value);
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
buffer = lines.pop() || ""; buffer = lines.pop() || "";
@@ -182,7 +181,6 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model)
if (!trimmed) continue; if (!trimmed) continue;
const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed; const jsonStr = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
if (!jsonStr || jsonStr === "[DONE]") { if (!jsonStr || jsonStr === "[DONE]") {
bufferedLines.push(trimmed);
stopLoop = true; stopLoop = true;
break; break;
} }
@@ -191,7 +189,6 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model)
try { try {
event = JSON.parse(jsonStr); event = JSON.parse(jsonStr);
} catch { } catch {
bufferedLines.push(trimmed);
continue; continue;
} }
@@ -201,8 +198,6 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model)
break; break;
} }
bufferedLines.push(trimmed);
if ( if (
event?.type === "text-delta" || event?.type === "text-delta" ||
event?.type === "reasoning-delta" || event?.type === "reasoning-delta" ||
@@ -245,29 +240,18 @@ export async function inspectAndWrapCommandCodeResponse(originalResponse, model)
); );
} }
const combinedStream = createReplayedStream(bufferedLines, buffer, reader); const combinedStream = createRawReplayedStream(rawChunks, reader);
return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse); return wrapNdjsonAsOpenAISse(combinedStream, model, originalResponse);
} }
function createReplayedStream(bufferedLines, remainingBuffer, reader) { function createRawReplayedStream(rawChunks, reader) {
const encoder = new TextEncoder(); let chunkIndex = 0;
let replayed = false;
return new ReadableStream({ return new ReadableStream({
async pull(controller) { async pull(controller) {
if (!replayed) { if (chunkIndex < rawChunks.length) {
replayed = true; controller.enqueue(rawChunks[chunkIndex++]);
let prefix = bufferedLines.join("\n"); return;
if (prefix && remainingBuffer) {
prefix += "\n" + remainingBuffer;
} else if (remainingBuffer) {
prefix = remainingBuffer;
} else if (prefix) {
prefix += "\n";
}
if (prefix) {
controller.enqueue(encoder.encode(prefix));
}
} }
try { try {

View File

@@ -1,12 +1,13 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js"; import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta, mergeAnthropicBeta } from "../providers/shared.js";
import { resolveOpenAICompatibleApiType } from "../services/provider.js"; import { resolveOpenAICompatibleApiType } from "../services/provider.js";
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../shared/clineAuth.js"; import { buildClineHeaders } from "../shared/clineAuth.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js"; import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
import { extractClaudeSessionIdFromUserId } from "../utils/claudeCloaking.js";
// Auth header descriptors — derived from registry transport.auth, fallback to hardcoded defaults. // Auth header descriptors — derived from registry transport.auth, fallback to hardcoded defaults.
const BEARER = { combined: true, header: "Authorization", scheme: "bearer" }; const BEARER = { combined: true, header: "Authorization", scheme: "bearer" };
@@ -164,9 +165,21 @@ export class DefaultExecutor extends BaseExecutor {
// a node fronting Kimi or GLM answers on its own ids and never matches, so // a node fronting Kimi or GLM answers on its own ids and never matches, so
// gateways that would choke on unknown beta flags are left untouched. // gateways that would choke on unknown beta flags are left untouched.
const isClaudeModel = typeof model === "string" && /^claude-/.test(model); const isClaudeModel = typeof model === "string" && /^claude-/.test(model);
const clientBeta = credentials?.rawHeaders?.["anthropic-beta"];
if (model && (this.provider === "claude" if (model && (this.provider === "claude"
|| (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) { || (this.provider?.startsWith?.("anthropic-compatible-") && isClaudeModel))) {
headers["Anthropic-Beta"] = selectAnthropicBeta(model, body); headers["Anthropic-Beta"] = mergeAnthropicBeta(selectAnthropicBeta(model, body), clientBeta);
} else if (this.provider === "anthropic" && clientBeta) {
headers["Anthropic-Beta"] = mergeAnthropicBeta(headers["Anthropic-Beta"], clientBeta);
}
// Claude OAuth: align x-claude-code-session-id with metadata.user_id.session_id if missing
if (this.provider === "claude" && !headers["x-claude-code-session-id"]) {
const token = credentials?.accessToken || credentials?.apiKey || "";
if (token.includes("sk-ant-oat")) {
const sid = extractClaudeSessionIdFromUserId(body?.metadata?.user_id);
if (sid) headers["x-claude-code-session-id"] = sid;
}
} }
// Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams

View File

@@ -1,8 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { DefaultExecutor } from "./default.js"; import { DefaultExecutor } from "./default.js";
import { resolveSessionId } from "../utils/sessionManager.js"; import { resolveSessionId } from "../utils/sessionManager.js";
import { modelTargetFormat } from "../providers/models/schema.js"; import { getModelTargetFormat } from "../config/providerModels.js";
import { getProviderModels } from "../config/providerModels.js"; import { FORMATS } from "../translator/formats.js";
import { import {
normalizeResponsesInput, normalizeResponsesInput,
clampResponsesCallId, clampResponsesCallId,
@@ -41,16 +41,10 @@ function translatedSession(sessionId, clientTool) {
return `ses_${digest}`; return `ses_${digest}`;
} }
// Strip the thinking suffix "model(level)" so checks hit the base id. // Responses-only per the provider registry (grok-4.6, gpt-5.6-luna, muse-spark, …),
function baseModelId(model) { // including the family-regex fallback for passthrough ids — never hardcode model ids here.
return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
}
// Responses-only per the provider registry (grok-4.6, gpt-5.6-luna, muse-spark, …).
// Reading the registry keeps this in sync with config — never hardcode model ids here.
function isResponsesModel(model) { function isResponsesModel(model) {
const entry = getProviderModels("opencode-go").find((m) => m.id === baseModelId(model)); return getModelTargetFormat("opencode-go", model) === FORMATS.OPENAI_RESPONSES;
return modelTargetFormat(entry) === "openai-responses";
} }
// Flatten Chat Completions tool declarations into the Responses flat shape and // Flatten Chat Completions tool declarations into the Responses flat shape and

View File

@@ -9,6 +9,7 @@ import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { upstreamResponseHeaders } from "../utils/upstreamHeaders.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js"; import { handleBypassRequest } from "../utils/bypassHandler.js";
import { trackPendingRequest, saveRequestDetail } from "@/lib/usageDb.js"; import { trackPendingRequest, saveRequestDetail } from "@/lib/usageDb.js";
@@ -493,7 +494,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`); log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`);
} }
reqLogger.logError(new Error(message), finalBody || translatedBody); reqLogger.logError(new Error(message), finalBody || translatedBody);
return createErrorResult(statusCode, errMsg, resetsAtMs); return createErrorResult(statusCode, errMsg, resetsAtMs, upstreamResponseHeaders(providerResponse.headers));
} }
const appendLog = () => {}; // request log derived from usageHistory; kept as no-op seam for handlers const appendLog = () => {}; // request log derived from usageHistory; kept as no-op seam for handlers

View File

@@ -4,6 +4,7 @@ import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js"; import { createErrorResult } from "../../utils/error.js";
import { upstreamResponseHeaders } from "../../utils/upstreamHeaders.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js"; import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js";
@@ -417,7 +418,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
return { return {
success: true, success: true,
response: new Response(JSON.stringify(restoreToolNames(translatedResponse, toolNameMap)), { response: new Response(JSON.stringify(restoreToolNames(translatedResponse, toolNameMap)), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", ...upstreamResponseHeaders(providerResponse.headers) }
}) })
}; };
} }

View File

@@ -20,6 +20,7 @@ import {
import { streamStatusForContent } from "../../utils/streamErrorPatterns.js"; import { streamStatusForContent } from "../../utils/streamErrorPatterns.js";
import { saveRequestDetail } from "@/lib/usageDb.js"; import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
import { upstreamResponseHeaders } from "../../utils/upstreamHeaders.js";
// Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat. // Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat.
// Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI. // Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI.
@@ -158,7 +159,12 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
return { return {
success: true, success: true,
response: new Response(transformedBody, { headers: SSE_HEADERS }), response: new Response(transformedBody, {
headers: {
...SSE_HEADERS,
...upstreamResponseHeaders(providerResponse.headers),
},
}),
}; };
} }

View File

@@ -0,0 +1,266 @@
import { Buffer } from "node:buffer";
// Gemini Live API realtime STT transport.
//
// The REST generateContent path (sttCore.transcribeGemini) only transcribes
// whole files inline. The Live API's `:bidiGenerateContent` WebSocket is the
// streaming counterpart: audio goes up as realtimeInput mediaChunks and the
// server pushes incremental `serverContent.inputTranscription` events back.
// This module owns the socket lifecycle only — envelope/response shaping
// stays in sttCore so the engine's single STT exit shape is preserved.
//
// Marker contract: dispatched from sttCore's format-switch when the model
// entry carries `transport: "gemini-live"` (registry) or the caller passes a
// transport string (custom models). Never keyed on a hardcoded model id here.
//
// Transport behavior:
// - Node >= 22 global WebSocket (undici). No new dependency.
// - Live API expects low-latency PCM; other containers are forwarded with
// their declared MIME unchanged (provider-side rejection is surfaced).
// - Text accumulation is append-only over inputTranscription segments and
// ends on serverContent.turnComplete (or graceful close with partial text).
// - Transcription deltas are kept per-frame (chunks[]) so sttCore can shape
// verbose_json segments without fabricating timestamps. goAway advisements
// rotate the socket once per call: setup replay + byte-offset resume.
const SETUP_TIMEOUT_MS = 10_000; // open → setupComplete
const TURN_TIMEOUT_MS = 60_000; // audio streamed → turnComplete
const MAX_TIMEOUT_MS = 300_000; // clamp ceiling for client-supplied lifecycle knobs
const CHUNK_BYTES = 16_384; // ~0.5s of 16-bit 16kHz mono PCM
const GOAWAY_RECONNECTS = 1; // socket rotations honoured per call
class GeminiLiveError extends Error {
constructor(message, status) {
super(message);
this.name = "GeminiLiveError";
this.status = status || 502;
}
}
// REST base (https://host/v1beta/models) → Live WS base
// (wss://host/ws/api/v1beta/models), then the bidiGenerateContent endpoint.
function toLiveWsUrl(baseUrl, model, token) {
const url = new URL(baseUrl);
url.protocol = "wss:";
if (!url.pathname.startsWith("/ws/")) url.pathname = `/ws/api${url.pathname}`;
const base = url.toString().replace(/\/+$/, "");
return `${base}/${encodeURIComponent(model)}:bidiGenerateContent?key=${encodeURIComponent(token || "")}`;
}
// Bind socket events supporting BOTH handler styles: addEventListener
// (browser WebSocket, undici) and onopen/onmessage property assignment
// (minimal polyfills). Whichever the implementation exposes, it works.
function bindSocket(ws, { onOpen, onMessage, onError, onClose }) {
if (typeof ws.addEventListener === "function") {
ws.addEventListener("open", onOpen);
ws.addEventListener("message", onMessage);
ws.addEventListener("error", onError);
ws.addEventListener("close", onClose);
return;
}
ws.onopen = onOpen;
ws.onmessage = onMessage;
ws.onerror = onError;
ws.onclose = onClose;
}
function parseFrame(data) {
try {
return JSON.parse(typeof data === "string" ? data : String(data));
} catch {
return null; // non-JSON frames carry no Live API semantics
}
}
function firstStringField(formData, key) {
const v = typeof formData?.get === "function" ? formData.get(key) : null;
return typeof v === "string" && v.trim() ? v.trim() : "";
}
// Lifecycle knobs the live registry entry advertises in params[]
// (setup/turn timeouts). They ride the same formData pass-through sttCore
// gives every transport — no sttCore change needed to reach this leaf.
function firstNumberField(formData, key, fallback) {
const n = Number(firstStringField(formData, key));
return Number.isFinite(n) && n > 0 ? Math.min(n, MAX_TIMEOUT_MS) : fallback;
}
/**
* Transcribe an audio File via the Gemini Live bidirectional stream.
* @returns {Promise<{text: string, chunks: string[]}>} transcript plus the raw
* incremental inputTranscription deltas (sttCore shapes verbose_json from them).
* @throws {GeminiLiveError} with .status for the sttCore error envelope.
*/
export async function transcribeGeminiLive({ cfg, file, model, token, formData, mimeType }) {
const WS = globalThis.WebSocket;
if (!WS) throw new GeminiLiveError("Gemini Live transport needs global WebSocket (Node >= 22)", 502);
const buf = Buffer.from(await file.arrayBuffer());
if (!buf.length) throw new GeminiLiveError("Empty audio file", 400);
const instruction = firstStringField(formData, "prompt") || "Transcribe the spoken audio verbatim.";
const language = firstStringField(formData, "language");
const setupTimeoutMs = firstNumberField(formData, "setup_timeout_ms", SETUP_TIMEOUT_MS);
const turnTimeoutMs = firstNumberField(formData, "turn_timeout_ms", TURN_TIMEOUT_MS);
// system_instruction (registry param) overrides the built-in transcription
// directive wholesale; prompt/language only shape the default.
const instructionOverride = firstStringField(formData, "system_instruction");
const systemText = instructionOverride
|| (language ? `${instruction} Language: ${language}.` : instruction);
const wsUrl = toLiveWsUrl(cfg.baseUrl, model, token);
return await new Promise((resolve, reject) => {
let text = "";
const chunks = []; // raw inputTranscription deltas, shaped by sttCore
let settled = false;
let timer = null;
let goAwayTimer = null;
let ws = null;
let generation = 0; // socket identity: superseded closes never settle
let sentBytes = 0; // audio prefix already handed to the live socket
let goAwayReconnects = GOAWAY_RECONNECTS;
const arm = (ms, message) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fail(new GeminiLiveError(message, 504)), ms);
};
const shutdown = () => {
if (timer) { clearTimeout(timer); timer = null; }
if (goAwayTimer) { clearTimeout(goAwayTimer); goAwayTimer = null; }
// ws is null until the first open() dials (and stays null when the
// constructor throws) — fail() runs shutdown() on that path.
if (!ws) return;
try {
if (ws.readyState === WS.OPEN || ws.readyState === WS.CONNECTING) ws.close(1000);
} catch { /* socket already dead — outcome is already settled */ }
};
const succeed = () => {
if (settled) return;
settled = true;
shutdown();
resolve({ text, chunks });
};
const fail = (err) => {
if (settled) return;
settled = true;
shutdown();
reject(err);
};
const send = (frame) => {
if (ws.readyState !== WS.OPEN) return false;
try {
ws.send(JSON.stringify(frame));
} catch {
return false; // socket died mid-send — streamAudioAndPrompt maps this to a 502
}
return true;
};
// Streams every byte not yet sent, then the flushing text turn. After a
// goAway rotation this resumes from sentBytes — no audio re-upload.
const streamAudioAndPrompt = () => {
for (let off = sentBytes; off < buf.length; off += CHUNK_BYTES) {
const mediaChunk = buf.subarray(off, off + CHUNK_BYTES).toString("base64");
if (!send({ realtimeInput: { mediaChunks: [{ mimeType, data: mediaChunk }] } })) {
fail(new GeminiLiveError("Gemini Live socket closed while streaming audio", 502));
return;
}
sentBytes = Math.min(off + CHUNK_BYTES, buf.length);
}
// Final user turn: flushes the recognizer and yields turnComplete.
send({ clientContent: { turns: [{ parts: [{ text: systemText }] }], turnComplete: true } });
};
// goAway: the server names the instant it will force-close this socket.
// Graceful play = rotate BEFORE the deadline: retire the live socket,
// dial a fresh one, replay setup, resume audio from sentBytes — text and
// chunks survive the hop. Once the advisory budget is spent a later
// goAway is left to the close path, which settles on partial transcript.
const scheduleGoAwayReconnect = (goAway) => {
if (settled || goAwayTimer || goAwayReconnects <= 0) return;
const deadline = Date.parse(typeof goAway?.time === "string" ? goAway.time : "");
const delay = Number.isFinite(deadline)
? Math.max(0, Math.min(deadline - Date.now(), setupTimeoutMs))
: 0;
goAwayTimer = setTimeout(() => {
goAwayTimer = null;
if (settled) return;
goAwayReconnects--;
generation++;
try { ws?.close(1000); } catch { /* deadline crossed mid-flight — re-dial anyway */ }
open();
}, delay);
};
const open = () => {
const gen = ++generation;
try {
ws = new WS(wsUrl);
} catch {
fail(new GeminiLiveError("Gemini Live websocket connection failed", 502));
return;
}
bindSocket(ws, {
onOpen: () => {
if (settled || gen !== generation) return;
arm(setupTimeoutMs, "Gemini Live timed out waiting for setupComplete");
send({
setup: {
model: `models/${model}`,
generationConfig: {
responseModalities: ["TEXT"],
inputAudioTranscription: {},
},
systemInstruction: { parts: [{ text: systemText }] },
},
});
},
onMessage: (ev) => {
if (settled || gen !== generation) return;
const frame = parseFrame(ev?.data);
if (!frame) return;
if (frame.error) {
const e = frame.error;
fail(new GeminiLiveError(`Gemini Live error${e.status ? ` (${e.status})` : ""}: ${e.message || "unknown"}`, 502));
return;
}
if (frame.goAway) {
scheduleGoAwayReconnect(frame.goAway);
return;
}
const sc = frame.serverContent;
if (!sc) return;
const delta = typeof sc.inputTranscription?.text === "string" ? sc.inputTranscription.text : "";
// Trim before testing: a padding-only frame carries no transcript and
// must not make an empty run look like a partial success on close.
if (delta.trim()) {
text += delta;
chunks.push(delta);
}
if (sc.setupComplete) {
arm(turnTimeoutMs, "Gemini Live transcription timed out");
streamAudioAndPrompt();
return;
}
if (sc.turnComplete) succeed();
},
onError: () => {
if (settled || gen !== generation) return;
fail(new GeminiLiveError("Gemini Live websocket connection failed", 502));
},
onClose: (ev) => {
if (settled || gen !== generation) return;
// Partial transcript beats a hard error on graceful close; silence is one.
if (text.trim()) succeed();
else fail(new GeminiLiveError(`Gemini Live socket closed before completion${ev?.code ? ` (code ${ev.code})` : ""}`, 502));
},
});
};
open();
});
}

View File

@@ -1,5 +1,7 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { createErrorResult } from "../utils/error.js"; import { createErrorResult } from "../utils/error.js";
import { transcribeGeminiLive } from "./geminiLiveStt.js";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js";
// Build auth headers from sttConfig + token // Build auth headers from sttConfig + token
@@ -162,11 +164,26 @@ function jsonResponse(obj) {
}; };
} }
// Model-level transport marker (registry models[].transport, e.g. the Gemini
// live STT entry's "gemini-live", or a custom model's stored transport).
// Dispatch reads the marker — never a hardcoded model id — so new realtime
// providers extend sttCore through data, not code.
function resolveModelTransport(provider, model) {
const key = PROVIDER_ID_TO_ALIAS[provider] || provider;
const models = PROVIDER_MODELS[key] || PROVIDER_MODELS[provider];
if (!Array.isArray(models)) return null;
const entry = models.find((m) => m && m.id === model && (m.kind || "llm") === "stt");
const marker = typeof entry?.transport === "string" ? entry.transport.trim() : "";
return marker || null;
}
/** /**
* STT core handler — dispatch by sttConfig.format. * STT core handler — dispatch by model transport marker, else sttConfig.format.
* `transport` is the caller-supplied marker override (custom models resolve
* it in the app layer; built-ins fall back to the registry entry marker).
* @returns {Promise<{success, response, status?, error?}>} * @returns {Promise<{success, response, status?, error?}>}
*/ */
export async function handleSttCore({ provider, model, formData, credentials, sttConfig }) { export async function handleSttCore({ provider, model, formData, credentials, sttConfig, transport }) {
const file = formData.get("file"); const file = formData.get("file");
if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file");
@@ -186,8 +203,29 @@ export async function handleSttCore({ provider, model, formData, credentials, st
return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`); return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`);
} }
// Format-switch extension: an explicit caller marker wins over the registry
// marker; with neither, the provider-default sttConfig.format applies.
const marker = (typeof transport === "string" && transport.trim()) ? transport.trim() : resolveModelTransport(provider, model);
try { try {
switch (cfg.format) { switch (marker || cfg.format) {
case "gemini-live": {
const live = await transcribeGeminiLive({ cfg, file, model, token, formData, mimeType: resolveAudioContentType(file) });
// response_format parity with the OpenAI-compatible transport: default
// envelope stays {text}; verbose_json adds segments mapped from the
// Live API's incremental inputTranscription deltas. Those frames carry
// NO timestamps, so segments expose {id,text} only (id = delta order,
// Whisper-compatible 0-based) — start/end/duration are deliberately
// absent rather than fabricated as zeros, which would misrepresent
// provider data to callers diffing transports.
const fmt = typeof formData?.get === "function"
? String(formData.get("response_format") ?? "").trim().toLowerCase()
: "";
if (fmt === "verbose_json") {
return jsonResponse({ text: live.text, segments: live.chunks.map((segText, id) => ({ id, text: segText })) });
}
return jsonResponse({ text: live.text });
}
case "deepgram": return await transcribeDeepgram(cfg, file, model, token, formData); case "deepgram": return await transcribeDeepgram(cfg, file, model, token, formData);
case "assemblyai": return await transcribeAssemblyAI(cfg, file, model, token); case "assemblyai": return await transcribeAssemblyAI(cfg, file, model, token);
case "nvidia-asr": return await transcribeNvidia(cfg, file, model, token); case "nvidia-asr": return await transcribeNvidia(cfg, file, model, token);
@@ -196,6 +234,6 @@ export async function handleSttCore({ provider, model, formData, credentials, st
default: return await transcribeOpenAICompatible(cfg, file, model, token, formData); default: return await transcribeOpenAICompatible(cfg, file, model, token, formData);
} }
} catch (err) { } catch (err) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed"); return createErrorResult(err.status || HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed");
} }
} }

View File

@@ -430,21 +430,29 @@ const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
* *
* @param {string[]} comboModels * @param {string[]} comboModels
* @param {Object|null} [comboLookup] optional map of combo name → models array for nested resolution * @param {Object|null} [comboLookup] optional map of combo name → models array for nested resolution
* @param {Function|null} [resolveCaps] optional (fullId) → caps override. The synced model
* catalog is server-only (it reads a file), so a browser-side resolution cannot see the
* limits it supplies and silently falls back to the generic patterns below. Callers that
* have the server's answer (/api/models, via useModelCaps) pass it here; it is merged over
* the local tables, so fields it does not carry (tools, pdf, audio/video, thinking*) survive.
* @param {number} [_depth] internal recursion depth guard * @param {number} [_depth] internal recursion depth guard
* @returns {object|null} full capabilities object, or null for empty input * @returns {object|null} full capabilities object, or null for empty input
*/ */
export function aggregateComboCapabilities(comboModels, comboLookup = null, _depth = 0) { export function aggregateComboCapabilities(comboModels, comboLookup = null, resolveCaps = null, _depth = 0) {
if (!comboModels?.length || _depth > 6) return null; if (!comboModels?.length || _depth > 6) return null;
const allCaps = comboModels.map((fullId) => { const allCaps = comboModels.map((fullId) => {
// Nested combo: bare name (no slash) that exists in the lookup — recurse // Nested combo: bare name (no slash) that exists in the lookup — recurse
if (!fullId.includes("/") && comboLookup?.[fullId]) { if (!fullId.includes("/") && comboLookup?.[fullId]) {
return aggregateComboCapabilities(comboLookup[fullId], comboLookup, _depth + 1) return aggregateComboCapabilities(comboLookup[fullId], comboLookup, resolveCaps, _depth + 1)
?? resolveCaps?.(fullId)
?? getCapabilitiesForModel(null, fullId); ?? getCapabilitiesForModel(null, fullId);
} }
const slash = fullId.indexOf("/"); const slash = fullId.indexOf("/");
const provider = slash === -1 ? null : fullId.slice(0, slash); const provider = slash === -1 ? null : fullId.slice(0, slash);
const model = slash === -1 ? fullId : fullId.slice(slash + 1); const model = slash === -1 ? fullId : fullId.slice(slash + 1);
return getCapabilitiesForModel(provider, model); const local = getCapabilitiesForModel(provider, model);
const override = resolveCaps?.(fullId);
return override ? { ...local, ...override } : local;
}); });
const first = allCaps[0]; const first = allCaps[0];
return { return {
@@ -482,7 +490,9 @@ const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
// handlers (silently: the setters still "succeed"). The slots therefore live on // handlers (silently: the setters still "succeed"). The slots therefore live on
// globalThis, which IS shared across server bundles in the same process. // globalThis, which IS shared across server bundles in the same process.
// Same reason the browser bundle is safe: it never calls a setter, so the slots // Same reason the browser bundle is safe: it never calls a setter, so the slots
// stay empty and every consumer below short-circuits. // stay empty and every consumer below short-circuits. Every read goes through
// globalThis: caching it locally would keep a reader alive in other copies after
// setCatalogSource(null).
let catalogSource = null; let catalogSource = null;
const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= { const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= {
catalog: null, // { getModalities, getLimits } — synced models.dev catalog catalog: null, // { getModalities, getLimits } — synced models.dev catalog
@@ -496,15 +506,13 @@ const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= {
*/ */
export function setCatalogSource(source) { export function setCatalogSource(source) {
catalogSource = source || null; catalogSource = source || null;
SOURCE_SLOTS.catalog = source || null; if (SOURCE_SLOTS) SOURCE_SLOTS.catalog = source || null;
if (typeof globalThis !== "undefined") globalThis.__9rCatalogSource = source || null; if (typeof globalThis !== "undefined") globalThis.__9rCatalogSource = source || null;
} }
function getCatalogSource() { function getCatalogSource() {
if (catalogSource) return catalogSource; if (typeof globalThis === "undefined") return catalogSource;
if (SOURCE_SLOTS.catalog) return (catalogSource = SOURCE_SLOTS.catalog); return SOURCE_SLOTS?.catalog || globalThis.__9rCatalogSource || null;
if (typeof globalThis === "undefined") return null;
return (catalogSource = globalThis.__9rCatalogSource || null);
} }
// Capabilities the user asserted per provider+model (dashboard "Add/Edit Model" // Capabilities the user asserted per provider+model (dashboard "Add/Edit Model"

View File

@@ -1,3 +1,5 @@
import { FORMATS } from "../../translator/formats.js";
// Codex auto-generates a "-review" variant for each llm model (review quota family) // Codex auto-generates a "-review" variant for each llm model (review quota family)
export const CODEX_REVIEW_SUFFIX = "-review"; export const CODEX_REVIEW_SUFFIX = "-review";
@@ -25,3 +27,20 @@ export function isMuseSparkModel(modelId) {
const base = clean.includes("/") ? clean.split("/").pop() : clean; const base = clean.includes("/") ? clean.split("/").pop() : clean;
return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base); return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base);
} }
// Endpoint families for OpenCode models outside the curated registry (modelsFetcher /
// passthrough ids) — regex keeps auto-fetched models on the right endpoint:
// /responses (gpt/grok/muse-spark), /messages (minimax/qwen), /chat/completions (rest).
// Curated registry entries always win; this is the unknown-id fallback only.
const OPENCODE_FAMILIES = [
{ match: /^(grok|gpt|muse[-_]?spark)/i, supportedFormats: [FORMATS.OPENAI_RESPONSES], targetFormat: FORMATS.OPENAI_RESPONSES },
{ match: /^deepseek-v4-(pro|flash)/, supportedFormats: [FORMATS.OPENAI, FORMATS.CLAUDE, FORMATS.OPENAI_RESPONSES] },
{ match: /^(minimax|qwen)/, supportedFormats: [FORMATS.OPENAI, FORMATS.CLAUDE] },
{ match: /^claude-/i, supportedFormats: [FORMATS.CLAUDE] },
];
export function opencodeFamilyFormats(modelId) {
if (!modelId || typeof modelId !== "string") return null;
const base = modelId.replace(/\([^()]+\)\s*$/, "").trim();
return OPENCODE_FAMILIES.find((f) => f.match.test(base)) || null;
}

View File

@@ -2,8 +2,28 @@
// //
// Fallback order (first match wins): // Fallback order (first match wins):
// 1. PROVIDER_PRICING[provider][model] — provider-specific override // 1. PROVIDER_PRICING[provider][model] — provider-specific override
// 2. MODEL_PRICING[model] — canonical model price (provider-agnostic) // 2. FREE_MODEL_NAMESPACES — upstream bills these at $0
// 3. PATTERN_PRICING — glob pattern match (e.g. "codex-*") // 3. MODEL_PRICING[model] — canonical model price (provider-agnostic)
// 4. PATTERN_PRICING — glob pattern match (e.g. "codex-*")
/**
* Namespaces upstream meters at $0. A free model must never inherit a paid
* rate: the vendor-prefix strip in getPricingForModel() would turn
* "cline-free/deepseek-v4.1-flash" into "deepseek-v4.1-flash" and match
* MODEL_PRICING, so the namespace is checked before both fallbacks.
*/
export const FREE_MODEL_NAMESPACES = ["cline-free/"];
export const ZERO_PRICING = {
input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0,
};
/** True when the model id sits in a namespace upstream bills at $0. */
export function isFreeModel(model) {
if (!model) return false;
const lower = String(model).toLowerCase();
return FREE_MODEL_NAMESPACES.some((ns) => lower.startsWith(ns));
}
/** /**
* Canonical model pricing — provider-agnostic. * Canonical model pricing — provider-agnostic.
@@ -361,10 +381,11 @@ export function matchPattern(pattern, model) {
} }
/** /**
* Resolve pricing for a model using the 3-step fallback chain: * Resolve pricing for a model using the 4-step fallback chain:
* 1. PROVIDER_PRICING[provider][model] * 1. PROVIDER_PRICING[provider][model]
* 2. MODEL_PRICING[model] * 2. free namespace (upstream bills $0)
* 3. PATTERN_PRICING (glob match) * 3. MODEL_PRICING[model]
* 4. PATTERN_PRICING (glob match)
* *
* @param {string} provider * @param {string} provider
* @param {string} model * @param {string} model
@@ -378,12 +399,15 @@ export function getPricingForModel(provider, model) {
return PROVIDER_PRICING[provider][model]; return PROVIDER_PRICING[provider][model];
} }
// 2. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat") // 2. Free namespaces bill $0 regardless of the model name behind them.
if (isFreeModel(model)) return ZERO_PRICING;
// 3. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat")
const baseModel = model.includes("/") ? model.split("/").pop() : model; const baseModel = model.includes("/") ? model.split("/").pop() : model;
if (MODEL_PRICING[baseModel]) return MODEL_PRICING[baseModel]; if (MODEL_PRICING[baseModel]) return MODEL_PRICING[baseModel];
if (MODEL_PRICING[model]) return MODEL_PRICING[model]; if (MODEL_PRICING[model]) return MODEL_PRICING[model];
// 3. Pattern match // 4. Pattern match
for (const { pattern, pricing } of PATTERN_PRICING) { for (const { pattern, pricing } of PATTERN_PRICING) {
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) { if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
return pricing; return pricing;

View File

@@ -0,0 +1,29 @@
export default {
id: "agnes",
priority: 120,
alias: "agnes",
aliases: [
"agnes-ai",
],
uiAlias: "agnes",
display: {
name: "Agnes AI",
icon: "auto_awesome",
color: "#7C3AED",
textIcon: "AG",
website: "https://agnes-ai.com",
notice: {
text: "OpenAI-compatible gateway from Agnes AI, offering free API credits on sign-up. Accepts a bearer token or an x-api-key header.",
apiKeyUrl: "https://platform.agnes-ai.com",
},
},
category: "freeTier",
authType: "apikey",
transport: {
baseUrl: "https://apihub.agnes-ai.com/v1/chat/completions",
validateUrl: "https://apihub.agnes-ai.com/v1/models",
},
// No model ids could be verified without a key, so discovery is left to the
// live endpoint and any id is accepted through passthroughModels.
passthroughModels: true,
};

View File

@@ -0,0 +1,32 @@
export default {
id: "atria",
priority: 120,
alias: "atria",
aliases: [
"atria-asi",
],
uiAlias: "atria",
display: {
name: "Atria Dawn",
icon: "flare",
color: "#C2410C",
textIcon: "AD",
website: "https://atria-asi.ai",
notice: {
text: "OpenAI-compatible endpoint from Atria Dawn (AtomInnoLab). Currently a research preview offering a single text model, Atria-Dawn-Preview.",
apiKeyUrl: "https://api.atria-asi.ai/dashboard",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.atria-asi.ai/v1/chat/completions",
validateUrl: "https://api.atria-asi.ai/v1/models",
},
// Docs pin the model field to one case-sensitive id. Text-only for now: the
// service ships a hook that blocks image/PDF input, so no vision is claimed.
models: [
{ id: "Atria-Dawn-Preview", name: "Atria Dawn Preview" },
],
passthroughModels: true,
};

View File

@@ -0,0 +1,30 @@
export default {
id: "bai",
priority: 120,
alias: "bai",
aliases: [
"b-ai",
],
uiAlias: "bai",
display: {
name: "B.AI",
icon: "account_balance",
color: "#0369A1",
textIcon: "BA",
website: "https://b.ai",
notice: {
text: "OpenAI-compatible gateway with one of the larger catalogues here. Accepts a bearer token or an x-api-key header. Model ids are fetched live from the provider.",
apiKeyUrl: "https://b.ai",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.b.ai/v1/chat/completions",
validateUrl: "https://api.b.ai/v1/models",
},
// No ids hardcoded: the catalogue is large and rotates, so the live endpoint
// is the source of truth and any id is accepted via passthroughModels.
modelsFetcher: { url: "https://api.b.ai/v1/models", type: "openai" },
passthroughModels: true,
};

View File

@@ -54,6 +54,8 @@ export default {
oauthUrl: "https://api.anthropic.com/api/oauth/usage", oauthUrl: "https://api.anthropic.com/api/oauth/usage",
orgUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage", orgUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
settingsUrl: "https://api.anthropic.com/v1/settings", settingsUrl: "https://api.anthropic.com/v1/settings",
profileUrl: "https://api.anthropic.com/api/oauth/profile",
resetUrl: "https://api.anthropic.com/api/organizations/{org_id}/reset_rate_limits",
}, },
}, },
models: [ models: [

View File

@@ -2,7 +2,8 @@ import { withCodexReviewModels } from "../models/helpers.js";
// Codex CLI version seen by OpenAI's backend — single source for the Version / // Codex CLI version seen by OpenAI's backend — single source for the Version /
// User-Agent identity headers. Bump when the installed codex CLI is upgraded. // User-Agent identity headers. Bump when the installed codex CLI is upgraded.
const CODEX_CLI_VERSION = "0.154.0"; const CODEX_CLI_VERSION = "0.155.0";
const GPT_6_LITE_THINKING_LEVELS = ["low", "medium", "high", "xhigh", "max"];
export default { export default {
id: "codex", id: "codex",
@@ -42,6 +43,7 @@ export default {
headers: { headers: {
originator: "codex_cli_rs", originator: "codex_cli_rs",
"User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}`, "User-Agent": `codex_cli_rs/${CODEX_CLI_VERSION}`,
version: CODEX_CLI_VERSION,
}, },
usage: { usage: {
url: "https://chatgpt.com/backend-api/wham/usage", url: "https://chatgpt.com/backend-api/wham/usage",
@@ -51,6 +53,8 @@ export default {
}, },
models: [ models: [
{ id: "gpt-6-astra", name: "GPT 6.0 Astra" }, { id: "gpt-6-astra", name: "GPT 6.0 Astra" },
{ id: "gpt-6-sol", name: "GPT 6.0 Sol", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS },
{ id: "gpt-6-luna", name: "GPT 6.0 Luna", responsesLite: true, thinkingLevels: GPT_6_LITE_THINKING_LEVELS },
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
{ id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" }, { id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },

View File

@@ -0,0 +1,35 @@
export default {
id: "dahl",
priority: 120,
alias: "dahl",
aliases: [
"dahl-inference",
],
uiAlias: "dahl",
display: {
name: "Dahl Inference",
icon: "hub",
color: "#1E40AF",
textIcon: "DH",
website: "https://dahl.global",
notice: {
text: "OpenAI-compatible Gonka inference node. Small, fixed catalogue (GLM-5.3-Flash, DeepSeek-V4-Flash, MiniMax-M2.7) at a flat per-token rate.",
apiKeyUrl: "https://dahl.global/dashboard",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://inference.dahl.global/v1/chat/completions",
validateUrl: "https://inference.dahl.global/v1/models",
},
// The live catalogue is public (no auth), so modelsFetcher works without a key
// and the ids below are a convenience seed rather than an exhaustive list.
models: [
{ id: "zai-org/GLM-5.3-Flash", name: "GLM-5.3 Flash" },
{ id: "deepseek-ai/DeepSeek-V4-Flash-0731", name: "DeepSeek V4 Flash 0731" },
{ id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" },
],
modelsFetcher: { url: "https://inference.dahl.global/v1/models", type: "openai" },
passthroughModels: true,
};

View File

@@ -58,6 +58,7 @@ export default {
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", params: ["language","prompt"], kind: "stt" }, { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", params: ["language","prompt"], kind: "stt" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", params: ["language","prompt"], kind: "stt" }, { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", params: ["language","prompt"], kind: "stt" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", params: ["language","prompt"], kind: "stt" }, { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", params: ["language","prompt"], kind: "stt" },
{ id: "gemini-2.5-flash-native-audio-preview-09-17", name: "Gemini Live Transcription (Realtime)", params: ["language","prompt","system_instruction","setup_timeout_ms","turn_timeout_ms"], kind: "stt", transport: "gemini-live" },
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", kind: "tts" }, { id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", kind: "tts" },
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", kind: "tts" }, { id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", kind: "tts" },
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", kind: "tts" }, { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", kind: "tts" },

View File

@@ -125,6 +125,11 @@ import p119 from "./selfhosted-embedding.js";
import p120 from "./fish-audio.js"; import p120 from "./fish-audio.js";
import p121 from "./alitp-intl.js"; import p121 from "./alitp-intl.js";
import p122 from "./xquik.js"; import p122 from "./xquik.js";
import p125 from "./tokenharbor.js";
import p126 from "./dahl.js";
import p127 from "./atria.js";
import p129 from "./agnes.js";
import p130 from "./bai.js";
export default [ export default [
p0, p0,
p1, p1,
@@ -250,4 +255,9 @@ export default [
p120, p120,
p121, p121,
p122, p122,
p125,
p126,
p127,
p129,
p130,
]; ];

View File

@@ -35,37 +35,56 @@ export default {
], ],
// supportedFormats follow the endpoint table in https://opencode.ai/docs/go/ // supportedFormats follow the endpoint table in https://opencode.ai/docs/go/
models: [ models: [
{ id: "deepseek-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai"] }, { id: "deepseek-flash", name: "DeepSeek Flash", supportedFormats: ["openai"] },
{ id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] }, { id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] },
{ id: "glm-5.3", name: "GLM 5.3", supportedFormats: ["openai"] }, { id: "glm-5.3", name: "GLM 5.3", supportedFormats: ["openai"] },
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
{ id: "glm-5", name: "GLM 5", supportedFormats: ["openai"] },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
{ id: "kimi-k2.5", name: "Kimi K2.5", supportedFormats: ["openai"] },
{ id: "kimi-k3", name: "Kimi K3", supportedFormats: ["openai"] }, { id: "kimi-k3", name: "Kimi K3", supportedFormats: ["openai"] },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] }, { id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (Exp)", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "longcat-2.0", name: "LongCat 2.0", supportedFormats: ["openai"] }, { id: "longcat-2.0", name: "LongCat 2.0", supportedFormats: ["openai"] },
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", supportedFormats: ["openai"] },
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", supportedFormats: ["openai"] },
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] }, { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] }, { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
{ id: "mimo-v2-pro", name: "MiMo V2 Pro", supportedFormats: ["openai"] },
{ id: "mimo-v2-omni", name: "MiMo V2 Omni", supportedFormats: ["openai"] },
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
{ id: "space-bunny-free", name: "Space Bunny Free", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.8-max", name: "Qwen 3.8 Max", supportedFormats: ["openai", "claude"] }, { id: "qwen3.8-max", name: "Qwen 3.8 Max", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.8-flash", name: "Qwen 3.8 Flash", supportedFormats: ["openai", "claude"] }, { id: "qwen3.8-flash", name: "Qwen 3.8 Flash", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus", supportedFormats: ["openai", "claude"] },
{ id: "hy4-preview", name: "Hy4 Preview", supportedFormats: ["openai"] }, { id: "hy4-preview", name: "Hy4 Preview", supportedFormats: ["openai"] },
{ id: "hy3", name: "Hy3", supportedFormats: ["openai"] }, { id: "hy3", name: "Hy3", supportedFormats: ["openai"] },
{ id: "hy3-preview", name: "Hy3 Preview", supportedFormats: ["openai"] },
// In /zen/go/v1/models but absent from the docs endpoint table — chat lane is the fallback guess
{ id: "omen-alpha", name: "Omen Alpha", supportedFormats: ["openai"] },
// Served by /zen/go/v1/responses only — the responses-only entry forces chatCore // Served by /zen/go/v1/responses only — the responses-only entry forces chatCore
// past the sourceFormat-matched transports into translation (see chatCore guard). // past the sourceFormat-matched transports into translation (see chatCore guard).
{ id: "grok-4.7", name: "Grok 4.7", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "grok-4.5", name: "Grok 4.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-6-luna", name: "GPT 6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
], ],
// Live catalogue; ids outside this curated list get their endpoint lane from the
// family regex in providers/models/helpers.js (opencodeFamilyFormats).
modelsFetcher: { url: "https://opencode.ai/zen/go/v1/models", type: "opencode-go" },
passthroughModels: true,
features: { features: {
usage: true, usage: true,
usageApikey: true, usageApikey: true,

View File

@@ -0,0 +1,49 @@
export default {
id: "tokenharbor",
priority: 120,
alias: "tokenharbor",
aliases: [
"th",
"thh",
],
uiAlias: "tokenharbor",
display: {
name: "Token Harbor",
icon: "anchor",
color: "#0F766E",
textIcon: "TH",
website: "https://tokenharbor.ai",
notice: {
text: "OpenAI-compatible aggregator. One API key reaches every model, billed per-token from a prepaid wallet. Model ids are bare (e.g. claude-opus-5.5, gpt-6-astra, deepseek-v4.1-flash:free) and are fetched live from the provider.",
apiKeyUrl: "https://tokenharbor.ai/dashboard",
},
},
category: "apikey",
authType: "apikey",
transport: {
// OpenAI-compatible. `format` is left at the shared "openai" default and
// `thinkingFormat` is deliberately NOT declared: Token Harbor forwards
// requests verbatim, so each model must resolve its own thinking wire
// format through providers/capabilities.js. Setting a provider-wide value
// would force one format (e.g. claude-adaptive) onto every model.
baseUrl: "https://tokenharbor.ai/v1/chat/completions",
validateUrl: "https://tokenharbor.ai/v1/models",
retry: {
429: 2,
},
},
// Curated seed; the live catalogue is fetched via modelsFetcher and any other
// id is accepted via passthroughModels. Their catalogue rotates (the :free set
// in particular), so this stays deliberately small and is only the offline
// fallback. Ids are bare — Token Harbor does not prefix them by upstream vendor.
models: [
{ id: "claude-opus-5.5", name: "Claude Opus 5.5" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "gpt-6-astra", name: "GPT-6 Astra" },
{ id: "gpt-6-sol", name: "GPT-6 Sol" },
{ id: "deepseek-v4.1-flash:free", name: "DeepSeek V4.1 Flash (Free)" },
{ id: "grok-4.7", name: "Grok 4.7" },
],
modelsFetcher: { url: "https://tokenharbor.ai/v1/models", type: "openai" },
passthroughModels: true,
};

View File

@@ -77,6 +77,11 @@ export function selectAnthropicBeta(model = "", body = null) {
return flags.join(","); return flags.join(",");
} }
export function mergeAnthropicBeta(...values) {
const flags = values.flatMap((v) => (typeof v === "string" ? v.split(",") : [])).map((f) => f.trim()).filter(Boolean);
return [...new Set(flags)].join(",");
}
// Shared baseUrls // Shared baseUrls
export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";

View File

@@ -3,6 +3,7 @@
import { getCapabilitiesForModel } from "./capabilities.js"; import { getCapabilitiesForModel } from "./capabilities.js";
import { matchPattern } from "./pricing.js"; import { matchPattern } from "./pricing.js";
import { resolveKiroEffortPath } from "../config/kiroConstants.js"; import { resolveKiroEffortPath } from "../config/kiroConstants.js";
import { getProviderModels } from "../config/providerModels.js";
// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat.
const L = { const L = {
@@ -42,6 +43,8 @@ const PATTERN_THINKING = [
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS }, { provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking { pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
{ pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] }, { pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] },
// mimo-v2.5-pro on opencode-go rejects reasoning_effort "max" (probed live); v2.5 accepts it.
{ pattern: "*mimo*v2.5-pro*", levels: ["none", "low", "medium", "high", "xhigh"] },
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max // DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
// all 200 via output_config.effort; "none" is a 400 on the anthropic route // all 200 via output_config.effort; "none" is a 400 on the anthropic route
// (disable thinking instead). none kept for the picker = disable. // (disable thinking instead). none kept for the picker = disable.
@@ -73,10 +76,14 @@ export function getThinkingLevels(provider, model) {
if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null;
const caps = getCapabilitiesForModel(provider, model); const caps = getCapabilitiesForModel(provider, model);
if (!caps.reasoning) return null; if (!caps.reasoning) return null;
const baseId = String(model || "").replace(/\([^()]+\)\s*$/, "");
const modelLevels = provider === "codex"
? getProviderModels("cx").find((entry) => entry.id === baseId)?.thinkingLevels
: null;
const hit = PATTERN_THINKING.find((entry) => const hit = PATTERN_THINKING.find((entry) =>
(!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model) (!entry.provider || entry.provider === provider) && matchPattern(entry.pattern, model)
); );
let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base; let levels = modelLevels || hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base;
if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none"); if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none");
return levels; return levels;
} }

View File

@@ -1,6 +1,12 @@
import { buildClineHeaders } from "../shared/clineAuth.js"; import { buildClineHeaders } from "../shared/clineAuth.js";
const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models"; const CLINEPASS_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/models";
// Cline's free tier is published here, not in /api/v1/models: the catalog
// endpoint carries no `cline-free/*` ids at all. Cline's own SDK calls this
// feed unauthenticated (sdk/packages/core/src/services/llms/cline-recommended-models.ts),
// so no Authorization header is sent — adding one would only make the request
// fail on a header the endpoint ignores.
const CLINE_RECOMMENDED_MODELS_ENDPOINT = "https://api.cline.bot/api/v1/ai/cline/recommended-models";
const FETCH_TIMEOUT_MS = 5000; const FETCH_TIMEOUT_MS = 5000;
/** /**
@@ -72,6 +78,40 @@ export async function resolveClinepassModels(credentials) {
return models.length ? { models } : null; return models.length ? { models } : null;
} }
/**
* Fetch Cline's recommended-models feed and return only its `free[]` tier.
* Returns null on any failure — the free tier is additive, so a dead feed must
* never take the /api/v1/models catalog down with it.
* @param {{accessToken?: string, apiKey?: string}} credentials
* @returns {Promise<{id: string, name: string}[] | null>}
*/
async function fetchClineFreeTierModels() {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(CLINE_RECOMMENDED_MODELS_ENDPOINT, {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) return null;
const json = await response.json();
const free = Array.isArray(json?.free) ? json.free : [];
if (!free.length) return null;
return free
.filter((m) => typeof m?.id === "string" && m.id.trim() !== "")
.map((m) => ({ id: m.id, name: m.name || m.id }));
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** /**
* Fetch Cline live model catalog from Cline's /models endpoint. * Fetch Cline live model catalog from Cline's /models endpoint.
* Unlike resolveClinepassModels, this returns ALL models (including * Unlike resolveClinepassModels, this returns ALL models (including
@@ -91,5 +131,15 @@ export async function resolveClineModels(credentials) {
name: m.name || m.id, name: m.name || m.id,
})); }));
return models.length ? { models } : null; // Free tier: /api/v1/models lists no `cline-free/*` ids, so merge the feed's
// free[] in. First writer wins on a shared id, keeping the catalog's entry
// for anything the two sources agree on.
const freeTier = await fetchClineFreeTierModels();
const byId = new Map(models.map((m) => [m.id, m]));
for (const m of freeTier || []) {
if (!byId.has(m.id)) byId.set(m.id, m);
}
const merged = Array.from(byId.values());
return merged.length ? { models: merged } : null;
} }

View File

@@ -4,10 +4,10 @@
import { getGitHubUsage } from "./usage/github.js"; import { getGitHubUsage } from "./usage/github.js";
import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js"; import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js";
import { getClaudeUsage } from "./usage/claude.js"; import { getClaudeUsage, consumeClaudeResetGrant } from "./usage/claude.js";
import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js"; import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js";
export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits }; export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits, consumeClaudeResetGrant };
import { getKiroUsage } from "./usage/kiro.js"; import { getKiroUsage } from "./usage/kiro.js";
import { getMiniMaxUsage } from "./usage/minimax.js"; import { getMiniMaxUsage } from "./usage/minimax.js";
import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js"; import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js";

View File

@@ -3,7 +3,7 @@
*/ */
import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { ANTHROPIC_API_VERSION } from "../../providers/shared.js"; import { ANTHROPIC_API_VERSION, CLAUDE_CLI_VERSION } from "../../providers/shared.js";
import { U, parseResetTime } from "./shared.js"; import { U, parseResetTime } from "./shared.js";
// Claude API config (urls from registry, apiVersion is header logic kept here) // Claude API config (urls from registry, apiVersion is header logic kept here)
@@ -11,7 +11,11 @@ const CLAUDE_CONFIG = {
oauthUsageUrl: U("claude").oauthUrl, oauthUsageUrl: U("claude").oauthUrl,
usageUrl: U("claude").orgUrl, usageUrl: U("claude").orgUrl,
settingsUrl: U("claude").settingsUrl, settingsUrl: U("claude").settingsUrl,
profileUrl: U("claude").profileUrl,
resetUrl: U("claude").resetUrl,
apiVersion: ANTHROPIC_API_VERSION, apiVersion: ANTHROPIC_API_VERSION,
// Reset grants are gated by surface: only "(external, cli)" UA is eligible
userAgent: `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`,
}; };
// OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it. // OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it.
@@ -64,12 +68,14 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
} }
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, { // cedar_ember=1 adds the "limit reset" grant block (same flag Claude Code sends)
const oauthResponse = await proxyAwareFetch(`${CLAUDE_CONFIG.oauthUsageUrl}?cedar_ember=1`, {
method: "GET", method: "GET",
headers: { headers: {
"Authorization": `Bearer ${accessToken}`, "Authorization": `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20", "anthropic-beta": "oauth-2025-04-20",
"anthropic-version": CLAUDE_CONFIG.apiVersion, "anthropic-version": CLAUDE_CONFIG.apiVersion,
"User-Agent": CLAUDE_CONFIG.userAgent,
}, },
}, proxyOptions); }, proxyOptions);
@@ -129,6 +135,7 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
return { return {
plan: "Claude Code", plan: "Claude Code",
extraUsage: data.extra_usage ?? null, extraUsage: data.extra_usage ?? null,
resetCredits: parseClaudeResetGrants(data.cedar_ember),
quotas, quotas,
}; };
} }
@@ -146,6 +153,70 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
} }
} }
// Free "limit reset" grants (Anthropic program id "cedar_ember").
// Shape: { eligible, next_grant_id, grants: [{ id, resets_left, ends_at, paused, clears }] }
export function parseClaudeResetGrants(block) {
if (!block?.eligible || !Array.isArray(block.grants)) return null;
const grants = block.grants.filter((g) => g?.id && !g.paused && Number(g.resets_left) > 0);
const next = grants.find((g) => g.id === block.next_grant_id) || grants[0] || null;
return {
availableCount: grants.reduce((sum, g) => sum + Number(g.resets_left), 0),
nextGrantId: next?.id || null,
expiresAt: next?.ends_at || null,
clears: next?.clears || [],
cooldownUntil: block.cooldown_until || null,
weeklyResetsAt: block.weekly_resets_at || null,
grants: block.grants.filter((g) => g?.id).map((g) => ({
id: g.id,
label: g.label || "",
resetsLeft: Number(g.resets_left) || 0,
resetsTotal: Number(g.resets_total) || 0,
startsAt: g.starts_at || null,
endsAt: g.ends_at || null,
clears: Array.isArray(g.clears) ? g.clears : [],
paused: g.paused === true,
usableNow: g.usable_now === true,
useRequiresLimit: g.use_requires_limit !== false,
})),
};
}
// Spend one reset grant: refills the limits listed in grant.clears. Irreversible.
export async function consumeClaudeResetGrant(accessToken, grantId, proxyOptions = null) {
if (!accessToken) throw new Error("No Claude access token available. Please re-authorize the connection.");
if (!/^[a-z0-9_-]{1,40}$/.test(grantId || "")) throw new Error("Invalid reset grant id.");
const headers = {
"Authorization": `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
"User-Agent": CLAUDE_CONFIG.userAgent,
"Content-Type": "application/json",
};
const profileRes = await proxyAwareFetch(CLAUDE_CONFIG.profileUrl, { method: "GET", headers }, proxyOptions);
const profile = await profileRes.json().catch(() => null);
const orgId = profile?.organization?.uuid;
if (!profileRes.ok || !orgId) throw new Error(`Cannot resolve Claude organization (${profileRes.status}).`);
const res = await proxyAwareFetch(CLAUDE_CONFIG.resetUrl.replace("{org_id}", orgId), {
method: "POST",
headers,
body: JSON.stringify({ program: "cedar_ember", grant_id: grantId, request_id: crypto.randomUUID() }),
}, proxyOptions);
const data = await res.json().catch(() => null);
usageCache.delete(accessToken); // next read must show refilled limits
return {
ok: res.ok && data?.result === "reset",
status: res.status,
result: data?.result || null,
reason: data?.reason || null,
resetsLeft: data?.resets_left ?? null,
message: data?.error?.message || null,
};
}
/** /**
* Legacy Claude usage for API key / org admin users * Legacy Claude usage for API key / org admin users
*/ */

View File

@@ -333,6 +333,9 @@ export function createResponsesApiTransformStream(logger = null) {
// Regular text content // Regular text content
if (content) { if (content) {
// The answer starts, so thinking is over. Upstreams that send reasoning via
// reasoning_content never emit "</think>", so close it here rather than at finish.
closeReasoning(controller);
if (!state.msgItemAdded[idx]) { if (!state.msgItemAdded[idx]) {
state.msgItemAdded[idx] = true; state.msgItemAdded[idx] = true;
const msgId = `msg_${state.responseId}_${idx}`; const msgId = `msg_${state.responseId}_${idx}`;
@@ -372,6 +375,7 @@ export function createResponsesApiTransformStream(logger = null) {
// Handle tool_calls // Handle tool_calls
if (delta.tool_calls) { if (delta.tool_calls) {
closeReasoning(controller);
closeMessage(controller, idx); closeMessage(controller, idx);
for (const tc of delta.tool_calls) { for (const tc of delta.tool_calls) {

View File

@@ -102,9 +102,28 @@ export function extractThinking(body) {
return null; return null;
} }
// Capture thinking intent from a body. Alias of extractThinking, named for clarity // Capture thinking intent from a body before format translation strips it.
// at the call-site where intent is snapshotted before format translation. // Besides the effort, records whether an OpenAI-shaped client wants the thinking
export const captureThinking = extractThinking; // text itself: Claude returns it only with thinking.display "summarized", a field
// OpenAI has no equivalent for, so the intent cannot survive translation on its own.
export function captureThinking(body) {
const cfg = extractThinking(body);
if (!cfg || cfg.mode === "none") return cfg;
const display = openAIThinkingDisplay(body);
return display ? { ...cfg, display } : cfg;
}
function openAIThinkingDisplay(body) {
// Responses API: reasoning.summary is the explicit request for reasoning text.
if (body.reasoning && typeof body.reasoning === "object") {
const summary = body.reasoning.summary;
return typeof summary === "string" && summary && summary !== "none" ? "summarized" : undefined;
}
// Chat Completions has no summary knob. A client setting reasoning_effort is
// asking for reasoning, and reasoning_content is how it would receive it.
if (typeof body.reasoning_effort === "string") return "summarized";
return undefined;
}
const NATIVE_ONLY_FORMATS = new Set(["gemini-level", "gemini-budget", "claude-budget", "claude-adaptive", "kiro"]); const NATIVE_ONLY_FORMATS = new Set(["gemini-level", "gemini-budget", "claude-budget", "claude-adaptive", "kiro"]);
@@ -302,9 +321,12 @@ function applyFormat(fmt, body, cfg, caps, supportedLevels, display) {
case "deepseek": { case "deepseek": {
if (none && canDisable) { body.thinking = { type: "disabled" }; break; } if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
body.thinking = { type: "enabled" }; body.thinking = { type: "enabled" };
// DeepSeek: low/medium→high, xhigh/max→max. // DeepSeek: low/medium→high, xhigh/max→max. Some backends (mimo v2.5-pro/v2.6
// on opencode-go, probed live) 400 on "max" — clamp to high when the declared
// levels exclude it.
const level = toLevel(eff); const level = toLevel(eff);
body.reasoning_effort = level === "xhigh" || level === "max" ? "max" : "high"; const want = level === "xhigh" || level === "max" ? "max" : "high";
body.reasoning_effort = want === "max" && supportedLevels && !supportedLevels.includes("max") ? "high" : want;
break; break;
} }
case "kimi": { case "kimi": {
@@ -380,7 +402,8 @@ export function applyThinking(targetFormat, model, body, provider = null, intent
const supportedLevels = getThinkingLevels(provider, cleanModel); const supportedLevels = getThinkingLevels(provider, cleanModel);
// Anthropic's `display` (summarized | omitted) decides whether thinking text // Anthropic's `display` (summarized | omitted) decides whether thinking text
// comes back at all; keep what the client asked for instead of resetting it. // comes back at all; keep what the client asked for instead of resetting it.
const display = typeof body.thinking?.display === "string" ? body.thinking.display : undefined; // An OpenAI-shaped client's ask arrives via the captured intent instead.
const display = typeof body.thinking?.display === "string" ? body.thinking.display : intent?.display;
stripAll(body); stripAll(body);
applyFormat(fmt, body, cfg, caps, supportedLevels, display); applyFormat(fmt, body, cfg, caps, supportedLevels, display);
return body; return body;

View File

@@ -432,7 +432,7 @@ export function cleanJSONSchemaForAntigravity(schema) {
return cleaned; return cleaned;
} }
// Merge adjacent same-role messages, strip empty parts, ensure initial user turn // Merge adjacent same-role messages, strip empty parts, ensure initial and terminal user turns
export function normalizeGeminiContents(contents) { export function normalizeGeminiContents(contents) {
const out = []; const out = [];
for (const c of contents || []) { for (const c of contents || []) {
@@ -446,6 +446,23 @@ export function normalizeGeminiContents(contents) {
if (out.length > 0 && out[0].role !== "user") { if (out.length > 0 && out[0].role !== "user") {
out.unshift({ role: "user", parts: [{ text: "..." }] }); out.unshift({ role: "user", parts: [{ text: "..." }] });
} }
if (out.length > 0 && out.at(-1).role === "model") {
const fnCalls = (out.at(-1).parts || []).filter(p => p && p.functionCall);
if (fnCalls.length > 0) {
const responses = fnCalls.map(p => {
const call = p.functionCall || {};
const fr = {
name: call.name || "tool",
response: { result: "Continue." }
};
if (call.id) fr.id = call.id;
return { functionResponse: fr };
});
out.push({ role: "user", parts: responses });
} else {
out.push({ role: "user", parts: [{ text: "Continue." }] });
}
}
return out; return out;
} }

View File

@@ -59,10 +59,6 @@ export function claudeToOpenAIResponse(chunk, state) {
} }
if (block?.type === CLAUDE_BLOCK.TEXT) { if (block?.type === CLAUDE_BLOCK.TEXT) {
state.textBlockStarted = true; state.textBlockStarted = true;
} else if (block?.type === CLAUDE_BLOCK.THINKING) {
state.inThinkingBlock = true;
state.currentBlockIndex = chunk.index;
results.push(createChunk(state, { content: "<think>" }));
} else if (block?.type === CLAUDE_BLOCK.TOOL_USE) { } else if (block?.type === CLAUDE_BLOCK.TOOL_USE) {
const toolCallIndex = state.toolCallIndex++; const toolCallIndex = state.toolCallIndex++;
// Restore original tool name from mapping (Claude OAuth) // Restore original tool name from mapping (Claude OAuth)
@@ -89,6 +85,8 @@ export function claudeToOpenAIResponse(chunk, state) {
if (delta?.type === "text_delta" && delta.text) { if (delta?.type === "text_delta" && delta.text) {
results.push(createChunk(state, { content: delta.text })); results.push(createChunk(state, { content: delta.text }));
} else if (delta?.type === "thinking_delta" && delta.thinking) { } else if (delta?.type === "thinking_delta" && delta.thinking) {
// Thinking travels only in reasoning_content. No "<think>" markers in
// content: OpenAI-format clients render them as literal text.
results.push(createChunk(state, reasoningDelta(delta.thinking))); results.push(createChunk(state, reasoningDelta(delta.thinking)));
} else if (delta?.type === "input_json_delta" && delta.partial_json) { } else if (delta?.type === "input_json_delta" && delta.partial_json) {
const toolCall = state.toolCalls.get(chunk.index); const toolCall = state.toolCalls.get(chunk.index);
@@ -112,10 +110,6 @@ export function claudeToOpenAIResponse(chunk, state) {
state.serverToolBlockIndex = -1; state.serverToolBlockIndex = -1;
break; break;
} }
if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) {
results.push(createChunk(state, { content: "</think>" }));
state.inThinkingBlock = false;
}
state.textBlockStarted = false; state.textBlockStarted = false;
state.thinkingBlockStarted = false; state.thinkingBlockStarted = false;
break; break;

View File

@@ -129,12 +129,16 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
} }
if (content) { if (content) {
// The answer starts, so thinking is over. Upstreams that send reasoning via
// reasoning_content never emit "</think>", so close it here rather than at finish.
closeReasoning(state, emit);
emitTextContent(state, emit, idx, content); emitTextContent(state, emit, idx, content);
} }
} }
// Handle tool_calls (empty array is truthy; require a real call) // Handle tool_calls (empty array is truthy; require a real call)
if (delta.tool_calls && delta.tool_calls.length) { if (delta.tool_calls && delta.tool_calls.length) {
closeReasoning(state, emit);
closeMessage(state, emit, idx); closeMessage(state, emit, idx);
for (const tc of delta.tool_calls) { for (const tc of delta.tool_calls) {
emitToolCall(state, emit, tc); emitToolCall(state, emit, tc);
@@ -219,15 +223,19 @@ function closeReasoning(state, emit) {
part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf } part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }
}); });
const item = {
id: state.reasoningId,
type: RESPONSES_ITEM.REASONING,
summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }]
};
emit("response.output_item.done", { emit("response.output_item.done", {
type: "response.output_item.done", type: "response.output_item.done",
output_index: state.reasoningIndex, output_index: state.reasoningIndex,
item: { item
id: state.reasoningId,
type: RESPONSES_ITEM.REASONING,
summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }]
}
}); });
recordCompletedOutputItem(state, state.reasoningIndex, item);
} }
} }
@@ -291,16 +299,20 @@ function closeMessage(state, emit, idx) {
part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText } part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }
}); });
const item = {
id: msgId,
type: RESPONSES_ITEM.MESSAGE,
content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }],
role: ROLE.ASSISTANT
};
emit("response.output_item.done", { emit("response.output_item.done", {
type: "response.output_item.done", type: "response.output_item.done",
output_index: parseInt(idx), output_index: parseInt(idx),
item: { item
id: msgId,
type: RESPONSES_ITEM.MESSAGE,
content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }],
role: ROLE.ASSISTANT
}
}); });
recordCompletedOutputItem(state, parseInt(idx), item);
} }
} }
@@ -394,23 +406,51 @@ function closeToolCall(state, emit, idx) {
}); });
} }
const item = {
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
call_id: callId,
name: state.funcNames[idx] || ""
};
emit("response.output_item.done", { emit("response.output_item.done", {
type: "response.output_item.done", type: "response.output_item.done",
output_index: parseInt(idx), output_index: parseInt(idx),
item: { item
id: `${custom ? "ctc" : "fc"}_${callId}`,
type: custom ? RESPONSES_ITEM.CUSTOM_TOOL_CALL : RESPONSES_ITEM.FUNCTION_CALL,
...(custom ? { input: extractCustomToolInput(args) } : { arguments: args }),
call_id: callId,
name: state.funcNames[idx] || ""
}
}); });
recordCompletedOutputItem(state, parseInt(idx), item);
state.funcItemDone[idx] = true; state.funcItemDone[idx] = true;
state.funcArgsDone[idx] = true; state.funcArgsDone[idx] = true;
} }
} }
// response.completed carries the finished Response object, so response.output has
// to repeat the items already delivered in response.output_item.done. Clients that
// build their final result from the terminal event (GitHub Copilot CLI, the OpenAI
// SDK "final response" helpers) otherwise treat the turn as empty even though the
// text was streamed - see issue #4307.
//
// Keyed by output_index so a repeated close overwrites rather than duplicating the
// item, and ordered by output_index so response.output matches the order the items
// were emitted in. Lazily created because stream.js can hand us a state it built
// itself rather than one from initState().
function recordCompletedOutputItem(state, outputIndex, item) {
state.completedOutputItems ??= new Map();
const index = Number.isInteger(outputIndex) ? outputIndex : Number.parseInt(outputIndex, 10) || 0;
state.completedOutputItems.set(index, item);
}
function collectCompletedOutputItems(state) {
const recorded = state.completedOutputItems;
if (!(recorded instanceof Map) || recorded.size === 0) return [];
return [...recorded.entries()]
.sort((left, right) => left[0] - right[0])
.map(([, item]) => item);
}
function sendCompleted(state, emit) { function sendCompleted(state, emit) {
if (!state.completedSent) { if (!state.completedSent) {
state.completedSent = true; state.completedSent = true;
@@ -423,6 +463,7 @@ function sendCompleted(state, emit) {
status: "completed", status: "completed",
background: false, background: false,
error: null, error: null,
output: collectCompletedOutputItems(state),
...(state.responsesUsage ? { usage: state.responsesUsage } : {}) ...(state.responsesUsage ? { usage: state.responsesUsage } : {})
} }
}); });

View File

@@ -25,10 +25,25 @@ function deriveUuid(seed) {
function generateFakeUserID(sessionId, apiKey) { function generateFakeUserID(sessionId, apiKey) {
const deviceId = apiKey ? createHash("sha256").update(`device:${apiKey}`).digest("hex") : randomBytes(32).toString("hex"); const deviceId = apiKey ? createHash("sha256").update(`device:${apiKey}`).digest("hex") : randomBytes(32).toString("hex");
const accountUuid = apiKey ? deriveUuid(`account:${apiKey}`) : randomUUID(); const accountUuid = apiKey ? deriveUuid(`account:${apiKey}`) : randomUUID();
const sessionUuid = sessionId || randomUUID(); const cleanSessionId = typeof sessionId === "string" ? sessionId.replace(/^claude:/i, "").trim() : null;
const sessionUuid = cleanSessionId || randomUUID();
return `{"device_id":"${deviceId}","account_uuid":"${accountUuid}","session_id":"${sessionUuid}"}`; return `{"device_id":"${deviceId}","account_uuid":"${accountUuid}","session_id":"${sessionUuid}"}`;
} }
export function extractClaudeSessionIdFromUserId(userId) {
if (typeof userId !== "string" || !userId) return null;
if (userId[0] === "{") {
try {
const sid = JSON.parse(userId)?.session_id;
return typeof sid === "string" && sid ? sid.replace(/^claude:/i, "").trim() || null : null;
} catch {
return null;
}
}
const clean = userId.replace(/^claude:/i, "").trim();
return clean || null;
}
/** /**
* Cloak tools before sending to Claude provider (anti-ban): * Cloak tools before sending to Claude provider (anti-ban):
* - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[] * - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[]
@@ -89,14 +104,29 @@ export function cloakClaudeTools(body) {
}; };
} }
// Strip a trailing CLAUDE_TOOL_SUFFIX from a cloaked name as a last-resort
// fallback when the name isn't in toolNameMap (e.g. map lost across a retry/
// reconnect). Never strips decoy names — those are meant to reach the client
// unresolved so it can see "tool unavailable" instead of silently no-oping.
function stripCloakSuffix(name) {
if (typeof name !== "string" || !name.endsWith(CLAUDE_TOOL_SUFFIX)) return null;
if (CC_DEFAULT_TOOLS.has(name)) return null;
const original = name.slice(0, -CLAUDE_TOOL_SUFFIX.length);
return original.length > 0 ? original : null;
}
// Decloak tool_use names in non-streaming Claude response body (INPUT side) // Decloak tool_use names in non-streaming Claude response body (INPUT side)
export function decloakToolNames(body, toolNameMap) { export function decloakToolNames(body, toolNameMap) {
if (!toolNameMap?.size || !Array.isArray(body?.content)) return body; if (!Array.isArray(body?.content)) return body;
const content = body.content.map(block => { const content = body.content.map(block => {
if (block?.type === "tool_use" && toolNameMap.has(block.name)) { if (block?.type !== "tool_use") return block;
if (toolNameMap?.has(block.name)) {
return { ...block, name: toolNameMap.get(block.name) }; return { ...block, name: toolNameMap.get(block.name) };
} }
return block; // toolNameMap missing/stale for this name — fall back to suffix stripping
// rather than forwarding an unresolvable "<tool>_ide" name to the client.
const fallback = stripCloakSuffix(block.name);
return fallback ? { ...block, name: fallback } : block;
}); });
return { ...body, content }; return { ...body, content };
} }
@@ -111,19 +141,21 @@ export function decloakToolNames(body, toolNameMap) {
* name appears exactly once per call — on the content_block_start event of * name appears exactly once per call — on the content_block_start event of
* a tool_use block; argument deltas carry no name. * a tool_use block; argument deltas carry no name.
* *
* Unknown names (e.g. a CC decoy tool the model called anyway) pass through * Falls back to stripping the literal CLAUDE_TOOL_SUFFIX when the name isn't
* unchanged, matching the non-streaming decloak behavior. * in toolNameMap (map lost across a retry/reconnect), matching the
* non-streaming decloak behavior. Decoy tool names (real CC tool names) and
* anything else pass through unchanged.
* *
* @param {object|null} chunk - Parsed SSE event (may be null on stream flush) * @param {object|null} chunk - Parsed SSE event (may be null on stream flush)
* @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools() * @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools()
* @returns {object|null} The chunk, with the tool_use name restored when cloaked * @returns {object|null} The chunk, with the tool_use name restored when cloaked
*/ */
export function decloakStreamChunk(chunk, toolNameMap) { export function decloakStreamChunk(chunk, toolNameMap) {
if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk; if (!chunk || typeof chunk !== "object") return chunk;
if (chunk.type !== "content_block_start") return chunk; if (chunk.type !== "content_block_start") return chunk;
const block = chunk.content_block; const block = chunk.content_block;
if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk; if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk;
const original = toolNameMap.get(block.name); const original = toolNameMap?.get(block.name) || stripCloakSuffix(block.name);
if (!original) return chunk; if (!original) return chunk;
return { ...chunk, content_block: { ...block, name: original } }; return { ...chunk, content_block: { ...block, name: original } };
} }

View File

@@ -27,12 +27,13 @@ export function buildErrorBody(statusCode, message) {
* @param {string} message - Error message * @param {string} message - Error message
* @returns {Response} HTTP Response object * @returns {Response} HTTP Response object
*/ */
export function errorResponse(statusCode, message) { export function errorResponse(statusCode, message, extraHeaders = null) {
return new Response(JSON.stringify(buildErrorBody(statusCode, message)), { return new Response(JSON.stringify(buildErrorBody(statusCode, message)), {
status: statusCode, status: statusCode,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Access-Control-Allow-Origin": "*" "Access-Control-Allow-Origin": "*",
...extraHeaders
} }
}); });
} }
@@ -95,13 +96,13 @@ export async function parseUpstreamError(response, executor = null) {
* @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors * @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors
* @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }} * @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }}
*/ */
export function createErrorResult(statusCode, message, resetsAtMs) { export function createErrorResult(statusCode, message, resetsAtMs, extraHeaders = null) {
return { return {
success: false, success: false,
status: statusCode, status: statusCode,
error: message, error: message,
resetsAtMs, resetsAtMs,
response: errorResponse(statusCode, message) response: errorResponse(statusCode, message, extraHeaders)
}; };
} }
@@ -113,7 +114,7 @@ export function createErrorResult(statusCode, message, resetsAtMs) {
* @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s" * @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s"
* @returns {Response} * @returns {Response}
*/ */
export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman) { export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman, extraHeaders = null) {
const retryAfterSec = Math.max(Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000), 1); const retryAfterSec = Math.max(Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000), 1);
const msg = `${message} (${retryAfterHuman})`; const msg = `${message} (${retryAfterHuman})`;
return new Response( return new Response(
@@ -121,8 +122,10 @@ export function unavailableResponse(statusCode, message, retryAfter, retryAfterH
{ {
status: statusCode, status: statusCode,
headers: { headers: {
...extraHeaders,
"Content-Type": "application/json", "Content-Type": "application/json",
"Retry-After": String(retryAfterSec) // Intentionally mis-cased to prevent duplicate headers
"retry-after": String(retryAfterSec)
} }
} }
); );

View File

@@ -0,0 +1,12 @@
const FORWARDED = new Set(["retry-after", "x-should-retry"]);
const FORWARDED_PREFIX = "anthropic-ratelimit-";
export function upstreamResponseHeaders(headers) {
const out = {};
if (typeof headers?.forEach !== "function") return out;
headers.forEach((value, name) => {
const key = name.toLowerCase();
if (FORWARDED.has(key) || key.startsWith(FORWARDED_PREFIX)) out[key] = value;
});
return out;
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router-app", "name": "9router-app",
"version": "0.5.86", "version": "0.5.91",
"description": "9Router web dashboard", "description": "9Router web dashboard",
"private": true, "private": true,
"scripts": { "scripts": {

BIN
public/providers/agnes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
public/providers/atria.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/providers/bai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

BIN
public/providers/dahl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -57,6 +57,7 @@ export default function BaseUrlSelect({
const [mode, setMode] = useState(""); const [mode, setMode] = useState("");
const [customInput, setCustomInput] = useState(""); const [customInput, setCustomInput] = useState("");
const initializedRef = useRef(false); const initializedRef = useRef(false);
const currentUrlRef = useRef("");
const customInputRef = useRef(""); const customInputRef = useRef("");
useEffect(() => { useEffect(() => {
@@ -85,23 +86,34 @@ export default function BaseUrlSelect({
[requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1] [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, cloudEnabled, cloudUrl, savedPresets, withV1]
); );
// Prefer a saved preset matching the currently configured URL, else first option // Sync the active config URL without replacing edits unless the config itself changes.
useEffect(() => { useEffect(() => {
if (initializedRef.current) return;
if (!presetsLoaded || options.length === 0) return; if (!presetsLoaded || options.length === 0) return;
const normalizeUrl = (url) => (withV1 ? ensureV1(url) : stripSlash(url));
const current = normalizeUrl(currentUrl);
if (initializedRef.current && currentUrlRef.current === current) return;
initializedRef.current = true; initializedRef.current = true;
const current = stripSlash(currentUrl); currentUrlRef.current = current;
const matched = current const matched = current
? options.find((o) => o.saved && stripSlash(o.url) === current) ? options.find((o) => o.value !== CUSTOM_VALUE && normalizeUrl(o.url) === current)
: null; : null;
const target = matched || options.find((o) => o.value !== CUSTOM_VALUE); if (matched) {
if (target) { setCustomInput("");
customInputRef.current = "";
setMode(matched.value);
onChange(matched.url);
} else if (current) {
setCustomInput(current);
customInputRef.current = current;
setMode(CUSTOM_VALUE);
onChange(current);
} else {
const target = options.find((o) => o.value !== CUSTOM_VALUE);
if (!target) return;
setMode(target.value); setMode(target.value);
onChange(target.url); onChange(target.url);
} else {
setMode(CUSTOM_VALUE);
} }
}, [presetsLoaded, options, onChange, currentUrl]); }, [presetsLoaded, options, onChange, currentUrl, withV1]);
const handleSelect = (e) => { const handleSelect = (e) => {
const next = e.target.value; const next = e.target.value;

View File

@@ -3,10 +3,12 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image"; import Image from "next/image";
import ProviderIcon from "@/shared/components/ProviderIcon";
import BaseUrlSelect from "./BaseUrlSelect"; import BaseUrlSelect from "./BaseUrlSelect";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
import { rememberEndpoint } from "./cliEndpointPresets"; import { rememberEndpoint } from "./cliEndpointPresets";
import { getCurrentCodexProviderSettings, deriveProfileNameFromModel } from "./codexConfig";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) { export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null); const [codexStatus, setCodexStatus] = useState(initialStatus || null);
@@ -23,12 +25,23 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState(""); const [customBaseUrl, setCustomBaseUrl] = useState("");
const [profiles, setProfiles] = useState([]);
const [aliasInput, setAliasInput] = useState("");
const [modelInput, setModelInput] = useState("");
const [profileModalOpen, setProfileModalOpen] = useState(false);
const [creatingProfile, setCreatingProfile] = useState(false);
const [deletingProfile, setDeletingProfile] = useState(null);
const [copiedCommand, setCopiedCommand] = useState("");
useEffect(() => { useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) { fetchProfiles();
}, []);
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey && !codexStatus?.config) {
setSelectedApiKey(apiKeys[0].key); setSelectedApiKey(apiKeys[0].key);
} }
}, [apiKeys, selectedApiKey]); }, [apiKeys, selectedApiKey, codexStatus?.config]);
useEffect(() => { useEffect(() => {
if (initialStatus) setCodexStatus(initialStatus); if (initialStatus) setCodexStatus(initialStatus);
@@ -38,6 +51,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
if (isExpanded) { if (isExpanded) {
if (!codexStatus) checkCodexStatus(); if (!codexStatus) checkCodexStatus();
fetchModelAliases(); fetchModelAliases();
fetchProfiles();
} }
}, [isExpanded]); }, [isExpanded]);
@@ -51,24 +65,101 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
} }
}; };
// Parse model and subagent settings from config content const fetchProfiles = async () => {
try {
const res = await fetch("/api/cli-tools/codex-profiles");
const data = await res.json();
if (res.ok) setProfiles(data.profiles || []);
} catch (error) {
console.log("Error fetching codex profiles:", error);
}
};
const handleModelSelectForAlias = (model) => {
setProfileModalOpen(false);
const selectedModelId = model?.value || model?.id;
if (!selectedModelId) return;
setModelInput(selectedModelId);
const providerName = model?.provider || (selectedModelId.includes("/") ? selectedModelId.split("/")[0] : selectedModelId);
const existingNames = profiles.map((p) => p.name);
setAliasInput(deriveProfileNameFromModel(providerName, existingNames));
};
const handleAddProfileWithAlias = async () => {
const cleanAlias = aliasInput.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
const cleanModel = modelInput.trim();
if (!cleanAlias || !cleanModel) return;
setCreatingProfile(true);
try {
const res = await fetch("/api/cli-tools/codex-profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: cleanAlias,
model: cleanModel,
}),
});
const data = await res.json();
if (res.ok) {
setAliasInput("");
setModelInput("");
fetchProfiles();
} else {
setMessage({ type: "error", text: data.error || "Failed to add model" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setCreatingProfile(false);
}
};
const handleDeleteProfile = async (name) => {
setDeletingProfile(name);
try {
const res = await fetch("/api/cli-tools/codex-profiles", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (res.ok) fetchProfiles();
} catch (error) {
console.log("Error deleting codex profile:", error);
} finally {
setDeletingProfile(null);
}
};
const handleCopyCommand = async (cmd) => {
try {
await navigator.clipboard.writeText(cmd);
setCopiedCommand(cmd);
setTimeout(() => setCopiedCommand(""), 2000);
} catch (e) {
console.log("Copy failed", e);
}
};
// Sync only when config content changes so local form edits are retained.
useEffect(() => { useEffect(() => {
if (codexStatus?.config) { const config = codexStatus?.config;
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m); if (config) {
const { baseUrl, apiKey } = getCurrentCodexProviderSettings(config);
setCustomBaseUrl(baseUrl);
setSelectedApiKey(apiKey);
const modelMatch = config.match(/^model\s*=\s*"([^"]+)"/m);
if (modelMatch) setSelectedModel(modelMatch[1]); if (modelMatch) setSelectedModel(modelMatch[1]);
// Parse subagent settings // Parse subagent settings
const subagentModelMatch = codexStatus.config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m); const subagentModelMatch = config.match(/^default_subagent_model\s*=\s*"([^"]+)"/m);
if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]); if (subagentModelMatch) setSubagentModel(subagentModelMatch[1]);
} }
}, [codexStatus]); }, [codexStatus?.config]);
const getCurrentBaseUrl = () => { const currentBaseUrl = getCurrentCodexProviderSettings(codexStatus?.config).baseUrl;
const parsed = codexStatus?.config?.match(/base_url\s*=\s*"([^"]+)"/);
return parsed ? parsed[1] : "";
};
const currentBaseUrl = getCurrentBaseUrl();
const getConfigStatus = () => { const getConfigStatus = () => {
if (!codexStatus?.installed) return null; if (!codexStatus?.installed) return null;
@@ -79,7 +170,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const configStatus = getConfigStatus(); const configStatus = getConfigStatus();
const getEffectiveBaseUrl = () => { const getEffectiveBaseUrl = () => {
const url = customBaseUrl || `${baseUrl}/v1`; const url = (customBaseUrl || `${baseUrl}/v1`).replace(/\/+$/, "");
// Ensure URL ends with /v1 // Ensure URL ends with /v1
return url.endsWith("/v1") ? url : `${url}/v1`; return url.endsWith("/v1") ? url : `${url}/v1`;
}; };
@@ -89,7 +180,7 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api
const checkCodexStatus = async () => { const checkCodexStatus = async () => {
setCheckingCodex(true); setCheckingCodex(true);
try { try {
const res = await fetch("/api/cli-tools/codex-settings"); const res = await fetch("/api/cli-tools/codex-settings", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
setCodexStatus(data); setCodexStatus(data);
} catch (error) { } catch (error) {
@@ -366,6 +457,148 @@ default_subagent_model = "${effectiveSubagentModel}"
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config <span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
</Button> </Button>
</div> </div>
{/* Additional Models */}
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-main flex items-center gap-1.5">
<span className="material-symbols-outlined text-[16px] text-primary">layers</span>
Additional Models
</span>
{profiles.length > 0 && (
<span className="px-1.5 py-0.2 bg-primary/10 text-primary text-[10px] font-medium rounded-full">
{profiles.length}
</span>
)}
</div>
</div>
{/* Quick Add Model bar */}
<div className="flex flex-col gap-1.5 p-2.5 bg-surface/40 border border-border rounded-lg">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[10rem_1fr_auto_auto] sm:items-center">
<input
type="text"
value={aliasInput}
onChange={(e) => setAliasInput(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
placeholder="Alias (e.g. claude)"
className="w-full min-w-0 px-2.5 py-1.5 bg-surface rounded border border-border text-xs font-mono focus:outline-none focus:ring-1 focus:ring-primary/50"
onKeyDown={(e) => e.key === "Enter" && handleAddProfileWithAlias()}
/>
<div className="relative w-full min-w-0">
<input
type="text"
value={modelInput}
onChange={(e) => {
const val = e.target.value;
setModelInput(val);
const provider = val.includes("/") ? val.split("/")[0] : val;
setAliasInput(deriveProfileNameFromModel(provider, profiles.map((p) => p.name)));
}}
placeholder="provider/model-id"
className="w-full min-w-0 pl-2.5 pr-7 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
onKeyDown={(e) => e.key === "Enter" && handleAddProfileWithAlias()}
/>
{modelInput && (
<button
onClick={() => setModelInput("")}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => setProfileModalOpen(true)}
disabled={!activeProviders?.length}
className={`w-full sm:w-auto rounded border px-2.5 py-1.5 text-xs transition-colors whitespace-nowrap sm:shrink-0 ${
activeProviders?.length
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
</button>
<Button
variant="primary"
size="sm"
onClick={handleAddProfileWithAlias}
disabled={!aliasInput.trim() || !modelInput.trim() || creatingProfile}
loading={creatingProfile}
className="!h-7.5 whitespace-nowrap"
>
<span className="material-symbols-outlined text-[15px] mr-1">add</span>
Add
</Button>
</div>
</div>
{profiles.length === 0 ? (
<div className="flex flex-col items-center justify-center p-4 border border-dashed border-border rounded-lg text-center bg-surface/30">
<span className="material-symbols-outlined text-[20px] text-text-muted mb-1 opacity-60">
terminal
</span>
<p className="text-xs text-text-muted">
Only the main model is active. Add an alias above to configure more models for Codex CLI.
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{profiles.map((p) => {
const providerId = p.model.includes("/") ? p.model.split("/")[0] : p.name;
const isCopied = copiedCommand === p.command;
return (
<div
key={p.name}
className="flex items-center justify-between gap-2 p-2.5 bg-surface/50 border border-border hover:border-border-hover rounded-lg transition-colors group"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="size-6 flex items-center justify-center shrink-0 rounded bg-black/5 dark:bg-white/5 p-0.5">
<ProviderIcon providerId={providerId} size={18} fallbackText={p.name.slice(0, 2).toUpperCase()} />
</div>
<div className="min-w-0 flex flex-col">
<span className="font-medium text-xs text-text-main truncate">
{p.name}
</span>
<span className="text-[11px] text-text-muted truncate font-mono">
{p.model}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleCopyCommand(p.command)}
className={`flex items-center gap-1 px-2 py-1 rounded text-[11px] font-mono border transition-all ${
isCopied
? "bg-green-500/10 border-green-500/30 text-green-600 dark:text-green-400"
: "bg-surface border-border text-text-muted hover:text-text-main hover:border-primary/50 cursor-pointer"
}`}
title="Click to copy command"
>
<span className="material-symbols-outlined text-[13px]">
{isCopied ? "check" : "terminal"}
</span>
<span className="hidden md:inline">{p.command}</span>
<span className="md:hidden">copy</span>
</button>
<button
onClick={() => handleDeleteProfile(p.name)}
disabled={deletingProfile === p.name}
className="p-1 text-text-muted hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity rounded"
title="Delete model"
>
<span className="material-symbols-outlined text-[15px]">close</span>
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</> </>
)} )}
</div> </div>
@@ -395,6 +628,18 @@ default_subagent_model = "${effectiveSubagentModel}"
/> />
)} )}
{profileModalOpen && (
<ModelSelectModal
isOpen={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
onSelect={handleModelSelectForAlias}
selectedModel={modelInput}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex CLI"
/>
)}
<ManualConfigModal <ManualConfigModal
isOpen={showManualConfigModal} isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)} onClose={() => setShowManualConfigModal(false)}

View File

@@ -7,8 +7,10 @@ import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets"; import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect"; import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch"; import { matchKnownEndpoint } from "./cliEndpointMatch";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
const ENDPOINT = "/api/cli-tools/hermes-settings"; const ENDPOINT = "/api/cli-tools/hermes-settings";
const HERMES_ROLES = CLI_TOOLS.hermes?.roles || [];
export default function HermesToolCard({ export default function HermesToolCard({
tool, tool,
@@ -32,6 +34,8 @@ export default function HermesToolCard({
const [message, setMessage] = useState(null); const [message, setMessage] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState(""); const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState(""); const [selectedModel, setSelectedModel] = useState("");
const [roleModels, setRoleModels] = useState({});
const [modalTarget, setModalTarget] = useState("default");
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({}); const [modelAliases, setModelAliases] = useState({});
const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [showManualConfigModal, setShowManualConfigModal] = useState(false);
@@ -82,6 +86,12 @@ export default function HermesToolCard({
hasInitializedModel.current = true; hasInitializedModel.current = true;
const cfg = hermesStatus.settings?.model; const cfg = hermesStatus.settings?.model;
if (cfg?.default) setSelectedModel(cfg.default); if (cfg?.default) setSelectedModel(cfg.default);
const initial = {};
if (hermesStatus.settings?.delegation?.model) initial.delegation = hermesStatus.settings.delegation.model;
for (const [role, rcfg] of Object.entries(hermesStatus.settings?.auxiliary || {})) {
if (rcfg?.model) initial[role] = rcfg.model;
}
setRoleModels(initial);
} }
}, [hermesStatus]); }, [hermesStatus]);
@@ -126,7 +136,12 @@ export default function HermesToolCard({
body: JSON.stringify({ body: JSON.stringify({
baseUrl: getEffectiveBaseUrl(), baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse, apiKey: keyToUse,
model: selectedModel, selections: [
{ role: "default", model: selectedModel },
...Object.entries(roleModels)
.filter(([, model]) => model?.trim())
.map(([role, model]) => ({ role, model: model.trim() })),
],
}), }),
}); });
const data = await res.json(); const data = await res.json();
@@ -154,6 +169,7 @@ export default function HermesToolCard({
if (res.ok) { if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" }); setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel(""); setSelectedModel("");
setRoleModels({});
checkStatus(); checkStatus();
} else { } else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" }); setMessage({ type: "error", text: data.error || "Failed to reset settings" });
@@ -166,16 +182,35 @@ export default function HermesToolCard({
}; };
const handleModelSelect = (model) => { const handleModelSelect = (model) => {
setSelectedModel(model.value); if (modalTarget === "default") {
setSelectedModel(model.value);
} else {
setRoleModels((prev) => ({ ...prev, [modalTarget]: model.value }));
}
setModalOpen(false); setModalOpen(false);
}; };
const openModelModal = (target) => {
setModalTarget(target);
setModalOpen(true);
};
const getManualConfigs = () => { const getManualConfigs = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim()) const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey ? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>"); : (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`; const base = getEffectiveBaseUrl();
let yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`;
if (roleModels.delegation?.trim()) {
yamlContent += `delegation:\n model: "${roleModels.delegation.trim()}"\n provider: "custom"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`;
}
const auxRoles = Object.entries(roleModels).filter(([role, model]) => role !== "delegation" && model?.trim());
if (auxRoles.length > 0) {
yamlContent += `auxiliary:\n${auxRoles.map(([role, model]) =>
` ${role}:\n provider: "custom"\n model: "${model.trim()}"\n base_url: "${base}"\n api_key: \${OPENAI_API_KEY}\n`
).join("")}`;
}
const envContent = `OPENAI_API_KEY=${keyToUse}\n`; const envContent = `OPENAI_API_KEY=${keyToUse}\n`;
return [ return [
@@ -274,8 +309,49 @@ export default function HermesToolCard({
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" /> <input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" />
{selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>} {selectedModel && <button onClick={() => setSelectedModel("")} className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div> </div>
<button onClick={() => setModalOpen(true)} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button> <button onClick={() => openModelModal("default")} disabled={!hasActiveProviders} className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
</div> </div>
<details className="group">
<summary className="cursor-pointer select-none text-xs font-semibold text-text-main hover:text-primary transition-colors">
<span className="material-symbols-outlined align-middle text-[16px] text-text-muted group-open:rotate-90 transition-transform">chevron_right</span>
Model Roles (optional)
</summary>
<div className="mt-2 flex flex-col gap-1.5">
{HERMES_ROLES.map((role) => (
<div key={role.id} className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="truncate text-xs font-semibold text-text-main sm:text-right sm:text-sm" title={role.label}>{role.label}</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<div className="relative w-full min-w-0">
<input
type="text"
value={roleModels[role.id] || ""}
onChange={(e) => setRoleModels((prev) => ({ ...prev, [role.id]: e.target.value }))}
placeholder="inherit default"
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
/>
{roleModels[role.id] && (
<button
onClick={() => setRoleModels((prev) => ({ ...prev, [role.id]: "" }))}
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
onClick={() => openModelModal(role.id)}
disabled={!hasActiveProviders}
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
>
Select
</button>
</div>
))}
<p className="text-xs text-text-muted">Empty roles inherit the default model.</p>
</div>
</details>
</div> </div>
{message && ( {message && (
@@ -306,10 +382,10 @@ export default function HermesToolCard({
isOpen={modalOpen} isOpen={modalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onSelect={handleModelSelect} onSelect={handleModelSelect}
selectedModel={selectedModel} selectedModel={modalTarget === "default" ? selectedModel : roleModels[modalTarget] || ""}
activeProviders={activeProviders} activeProviders={activeProviders}
modelAliases={modelAliases} modelAliases={modelAliases}
title="Select Model for Hermes Agent" title={`Select Model for Hermes Agent${modalTarget !== "default" ? ` — ${HERMES_ROLES.find((r) => r.id === modalTarget)?.label || modalTarget}` : ""}`}
/> />
)} )}

View File

@@ -0,0 +1,86 @@
const parseTomlString = (line, key) => {
const match = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(["'])([^\\n]*?)\\1\\s*(?:#.*)?$`));
return match ? match[2] : "";
};
// Only inspect the active provider tables so other providers cannot affect the form.
export function getCurrentCodexProviderSettings(config) {
if (typeof config !== "string") return { baseUrl: "", apiKey: "" };
const lines = config.split(/\r?\n/);
let modelProvider = "";
let inRootTable = true;
for (const line of lines) {
if (/^\s*\[/.test(line)) {
inRootTable = false;
continue;
}
if (inRootTable) {
modelProvider = parseTomlString(line, "model_provider") || modelProvider;
}
}
if (!modelProvider) return { baseUrl: "", apiKey: "" };
const activeTable = `model_providers.${modelProvider}`;
let inActiveProviderTable = false;
let inActiveHeadersTable = false;
let baseUrl = "";
let apiKey = "";
for (const line of lines) {
const tableMatch = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/);
if (tableMatch) {
inActiveProviderTable = tableMatch[1] === activeTable;
inActiveHeadersTable = tableMatch[1] === `${activeTable}.http_headers`;
continue;
}
if (inActiveProviderTable) {
baseUrl = parseTomlString(line, "base_url") || baseUrl;
}
if (inActiveHeadersTable) {
const authorization = parseTomlString(line, "Authorization");
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
apiKey = bearerMatch ? bearerMatch[1] : apiKey;
}
}
return { baseUrl, apiKey };
}
export function getCurrentCodexProviderBaseUrl(config) {
return getCurrentCodexProviderSettings(config).baseUrl;
}
export function deriveProfileNameFromModel(modelId, existingNames = []) {
if (!modelId || typeof modelId !== "string") return "model";
let base = "";
const trimmed = modelId.trim();
if (trimmed.includes("/")) {
base = trimmed.split("/")[0].toLowerCase().replace(/[^a-z0-9_-]/g, "");
} else {
base = trimmed.toLowerCase().replace(/[^a-z0-9_-]/g, "");
}
base = base.replace(/^[-_]+|[-_]+$/g, "") || "model";
if (base === "config") base = "model-config";
let candidate = base;
let counter = 2;
while (existingNames.includes(candidate)) {
candidate = `${base}-${counter}`;
counter++;
}
return candidate;
}
export function buildCodexProfileToml({ name, model }) {
return `# codex -p ${name}\nmodel = "${model}"\nmodel_provider = "9router"\n`;
}
export function parseCodexProfileModel(content) {
if (typeof content !== "string") return "";
const match = content.match(/^\s*model\s*=\s*(["'])([^"\n]+)\1/m);
return match ? match[2] : "";
}

View File

@@ -687,7 +687,10 @@ function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], cop
const current = strategy.fallbackStrategy || globalStrategy || "fallback"; const current = strategy.fallbackStrategy || globalStrategy || "fallback";
const judge = strategy.judgeModel || ""; const judge = strategy.judgeModel || "";
const isFusion = current === "fusion"; const isFusion = current === "fusion";
const comboCaps = aggregateComboCapabilities(combo.models, comboByName); // The synced catalog is server-only, so resolving here would fall back to the
// generic patterns and under-report the limits. getCaps carries the server's
// answer for /api/models.
const comboCaps = aggregateComboCapabilities(combo.models, comboByName, getCaps);
return ( return (
<Card padding="sm" className={`group ${selected ? "ring-1 ring-primary/40 bg-primary/[0.03]" : ""}`}> <Card padding="sm" className={`group ${selected ? "ring-1 ring-primary/40 bg-primary/[0.03]" : ""}`}>
@@ -718,7 +721,7 @@ function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], cop
<span>{model}</span> <span>{model}</span>
<CapacityBadges caps={ <CapacityBadges caps={
comboByName[model] comboByName[model]
? aggregateComboCapabilities(comboByName[model], comboByName) ? aggregateComboCapabilities(comboByName[model], comboByName, getCaps)
: getCaps?.(model) : getCaps?.(model)
} /> } />
</code> </code>
@@ -894,8 +897,15 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps })
const patch = (p) => onChange({ ...entry, ...p }); const patch = (p) => onChange({ ...entry, ...p });
const handleAdd = (model) => { const handleAdd = (model) => {
if (models.includes(model.value)) return; const value = model?.value || model?.name || model;
patch({ models: [...models, model.value] }); if (!value || models.includes(value)) return;
patch({ models: [...models, value] });
};
const handleDeselect = (model) => {
const value = model?.value || model?.name || model;
const next = models.filter((m) => m !== value);
patch({ models: next.length === 0 ? [DEFAULT_FALLBACK_MODEL] : next });
}; };
const handleRemove = (index) => { const handleRemove = (index) => {
@@ -914,7 +924,7 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps })
return ( return (
<Card padding="sm" className={`group ${!enabled ? "opacity-50" : ""}`}> <Card padding="sm" className={`group ${!enabled ? "opacity-50" : ""}`}>
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Master toggle + icon + label + chips */} {/* Master toggle + icon + label */}
<div className="flex min-w-0 flex-1 items-start gap-2.5 sm:items-center"> <div className="flex min-w-0 flex-1 items-start gap-2.5 sm:items-center">
<Toggle <Toggle
checked={enabled} checked={enabled}
@@ -929,33 +939,6 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps })
<code className="font-mono text-sm font-medium">{cap.label}</code> <code className="font-mono text-sm font-medium">{cap.label}</code>
<span className="text-[10px] text-text-muted">— {cap.desc}</span> <span className="text-[10px] text-text-muted">— {cap.desc}</span>
</div> </div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-1">
{models.length === 0 ? (
<span className="text-xs text-text-muted italic">No models</span>
) : (
models.slice(0, 3).map((model, index) => (
<code
key={`${model}-${index}`}
className="group/chip inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5"
>
<span>{model}</span>
<CapacityBadges caps={getCaps?.(model)} />
<button onClick={() => handleMove(index, -1)} disabled={index === 0} className={`leading-none opacity-0 group-hover/chip:opacity-100 ${index === 0 ? "text-text-muted/20" : "text-text-muted hover:text-primary"}`}>
<span className="material-symbols-outlined text-[12px]">arrow_upward</span>
</button>
<button onClick={() => handleMove(index, 1)} disabled={index === models.length - 1} className={`leading-none opacity-0 group-hover/chip:opacity-100 ${index === models.length - 1 ? "text-text-muted/20" : "text-text-muted hover:text-primary"}`}>
<span className="material-symbols-outlined text-[12px]">arrow_downward</span>
</button>
<button onClick={() => handleRemove(index)} className="leading-none opacity-0 group-hover/chip:opacity-100 text-text-muted hover:text-red-500">
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</code>
))
)}
{models.length > 3 && (
<span className="text-[10px] text-text-muted">+{models.length - 3} more</span>
)}
</div>
</div> </div>
</div> </div>
@@ -983,11 +966,97 @@ function CapacityAdapterCap({ cap, entry, onChange, activeProviders, getCaps })
</div> </div>
</div> </div>
{/* Model pool list/table */}
{models.length === 0 ? (
<div className="mt-3 py-2 text-center text-xs text-text-muted italic">
No models in pool (will fallback to {DEFAULT_FALLBACK_MODEL})
</div>
) : (
<div className="mt-3 overflow-hidden rounded-lg border border-border/50">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-border/40 bg-black/[0.02] text-text-muted dark:bg-white/[0.02]">
<th className="w-12 px-3 py-1.5 font-medium text-center">#</th>
<th className="px-3 py-1.5 font-medium">Model</th>
<th className="w-24 px-3 py-1.5 font-medium text-center">Order</th>
<th className="w-12 px-3 py-1.5 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-border/30 font-mono">
{models.map((model, index) => (
<tr key={`${model}-${index}`} className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors">
<td className="px-3 py-2 text-center text-text-muted text-[11px] font-sans">
#{index + 1}
</td>
<td className="px-3 py-2 text-text-main">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="truncate">{model}</span>
<CapacityBadges caps={getCaps?.(model)} />
{model === DEFAULT_FALLBACK_MODEL && (
<span className="rounded bg-emerald-500/10 px-1.5 py-0.5 font-sans text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
free default
</span>
)}
</div>
</td>
<td className="px-3 py-2 text-center">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => handleMove(index, -1)}
disabled={!enabled || index === 0}
className={`p-1 rounded transition-colors ${
!enabled || index === 0
? "text-text-muted/20 cursor-not-allowed"
: "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
title="Move up"
>
<span className="material-symbols-outlined text-[16px] leading-none">arrow_upward</span>
</button>
<button
type="button"
onClick={() => handleMove(index, 1)}
disabled={!enabled || index === models.length - 1}
className={`p-1 rounded transition-colors ${
!enabled || index === models.length - 1
? "text-text-muted/20 cursor-not-allowed"
: "text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
title="Move down"
>
<span className="material-symbols-outlined text-[16px] leading-none">arrow_downward</span>
</button>
</div>
</td>
<td className="px-3 py-2 text-right">
<button
type="button"
onClick={() => handleRemove(index)}
disabled={!enabled}
className={`p-1 rounded transition-colors ${
!enabled
? "text-text-muted/20 cursor-not-allowed"
: "text-text-muted hover:text-red-500 hover:bg-red-500/10"
}`}
title="Remove model"
>
<span className="material-symbols-outlined text-[16px] leading-none">close</span>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showModelSelect && ( {showModelSelect && (
<ModelSelectModal <ModelSelectModal
isOpen={showModelSelect} isOpen={showModelSelect}
onClose={() => setShowModelSelect(false)} onClose={() => setShowModelSelect(false)}
onSelect={handleAdd} onSelect={handleAdd}
onDeselect={handleDeselect}
activeProviders={activeProviders} activeProviders={activeProviders}
title={`Add ${cap.label} Model`} title={`Add ${cap.label} Model`}
addedModelValues={models} addedModelValues={models}

View File

@@ -13,6 +13,7 @@ import {
CLIENT_PING_FAST_MS, CLIENT_PING_FAST_MS,
} from "./endpointConstants"; } from "./endpointConstants";
import { clientPingUrl, clientPingAny } from "./endpointPing"; import { clientPingUrl, clientPingAny } from "./endpointPing";
import useSettingsStore from "@/store/settingsStore";
import EndpointRow from "./components/EndpointRow"; import EndpointRow from "./components/EndpointRow";
import StatusAlert from "./components/StatusAlert"; import StatusAlert from "./components/StatusAlert";
import Tooltip from "./components/Tooltip"; import Tooltip from "./components/Tooltip";
@@ -211,16 +212,15 @@ export default function APIPageClient({ machineId }) {
const loadSettings = async () => { const loadSettings = async () => {
setTunnelChecking(true); setTunnelChecking(true);
try { try {
const [settingsRes, statusRes] = await Promise.all([ const [settingsData, statusRes] = await Promise.all([
fetch("/api/settings"), useSettingsStore.getState().fetchSettings(),
fetch("/api/tunnel/status", { cache: "no-store" }) fetch("/api/tunnel/status", { cache: "no-store" })
]); ]);
if (settingsRes.ok) { if (settingsData) {
const data = await settingsRes.json(); setRequireApiKey(settingsData.requireApiKey || false);
setRequireApiKey(data.requireApiKey || false); setRequireLogin(settingsData.requireLogin !== false);
setRequireLogin(data.requireLogin !== false); setHasPassword(settingsData.hasPassword || false);
setHasPassword(data.hasPassword || false); setTunnelDashboardAccess(settingsData.tunnelDashboardAccess || false);
setTunnelDashboardAccess(data.tunnelDashboardAccess || false);
} }
if (statusRes.ok) { if (statusRes.ok) {
const data = await statusRes.json(); const data = await statusRes.json();
@@ -246,12 +246,8 @@ export default function APIPageClient({ machineId }) {
const handleTunnelDashboardAccess = async (value) => { const handleTunnelDashboardAccess = async (value) => {
try { try {
const res = await fetch("/api/settings", { const updated = await useSettingsStore.getState().patchSettings({ tunnelDashboardAccess: value });
method: "PATCH", if (updated) setTunnelDashboardAccess(value);
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tunnelDashboardAccess: value }),
});
if (res.ok) setTunnelDashboardAccess(value);
} catch (error) { } catch (error) {
console.log("Error updating tunnelDashboardAccess:", error); console.log("Error updating tunnelDashboardAccess:", error);
} }
@@ -259,12 +255,8 @@ export default function APIPageClient({ machineId }) {
const handleRequireApiKey = async (value) => { const handleRequireApiKey = async (value) => {
try { try {
const res = await fetch("/api/settings", { const updated = await useSettingsStore.getState().patchSettings({ requireApiKey: value });
method: "PATCH", if (updated) setRequireApiKey(value);
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requireApiKey: value }),
});
if (res.ok) setRequireApiKey(value);
} catch (error) { } catch (error) {
console.log("Error updating requireApiKey:", error); console.log("Error updating requireApiKey:", error);
} }

View File

@@ -2,8 +2,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button, Modal, Toggle } from "@/shared/components"; import { Button, Modal, Select, Toggle } from "@/shared/components";
import { CAPACITY_META } from "@/shared/constants/models"; import { CAPACITY_META, STT_TRANSPORT_META, STT_TRANSPORTS } from "@/shared/constants/models";
const emptyCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false])); const emptyCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false]));
@@ -33,6 +33,8 @@ export default function AddCustomModelModal({
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
const [testError, setTestError] = useState(""); const [testError, setTestError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Realtime dispatch marker for the transport select; "" = provider default REST.
const [transport, setTransport] = useState("");
// Re-seeded on open and when the target model changes while already open // Re-seeded on open and when the target model changes while already open
// (row-click editing reuses one mounted modal). `capsKey` is a primitive so a // (row-click editing reuses one mounted modal). `capsKey` is a primitive so a
@@ -46,6 +48,7 @@ export default function AddCustomModelModal({
setModelId(initialModelId); setModelId(initialModelId);
setSeed(next); setSeed(next);
setCaps(next); setCaps(next);
setTransport("");
setTestStatus(null); setTestStatus(null);
setTestError(""); setTestError("");
}, [isOpen, initialModelId, capsKey]); }, [isOpen, initialModelId, capsKey]);
@@ -85,7 +88,14 @@ export default function AddCustomModelModal({
for (const key of Object.keys(CAPACITY_META)) { for (const key of Object.keys(CAPACITY_META)) {
if (!!caps[key] !== !!seed[key]) changed[key] = !!caps[key]; if (!!caps[key] !== !!seed[key]) changed[key] = !!caps[key];
} }
await onSave(cleanId, Object.keys(changed).length ? changed : undefined); if (caps.stt) changed.stt = true;
// caps.stt is UI-only; the parent save flow derives the model type from
// it and forwards the pinned transport (null unless the caller picked one).
await onSave(
cleanId,
Object.keys(changed).length ? changed : undefined,
caps.stt ? transport : null
);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -146,6 +156,32 @@ export default function AddCustomModelModal({
</div> </div>
</div> </div>
{/* STT is a model TYPE, not a chat capability: the save flow turns this
flag into type "stt" (the API honours a transport only on stt
records). The select pins the realtime dispatch marker persisted
with the model; the whitelist is the shared STT_TRANSPORT_META. */}
<div>
<Toggle
checked={!!caps.stt}
onChange={(v) => { setCaps((prev) => ({ ...prev, stt: v })); if (!v) setTransport(""); }}
label="Speech to text"
description="Transcribes audio via /v1/audio/transcriptions"
size="sm"
/>
{caps.stt && (
<div className="mt-3">
<Select
label="Transport"
value={transport}
onChange={(e) => setTransport(e.target.value)}
placeholder="Provider default (REST)"
options={STT_TRANSPORTS.map((t) => ({ value: t, label: STT_TRANSPORT_META[t].label }))}
hint="Realtime transport marker for the STT dispatcher. Empty keeps the provider's REST format."
/>
</div>
)}
</div>
{/* Test result */} {/* Test result */}
{testStatus === "ok" && ( {testStatus === "ok" && (
<div className="flex items-center gap-2 text-sm text-green-600"> <div className="flex items-center gap-2 text-sm text-green-600">

View File

@@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, ZedAuthModal, XiaomiMimoAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getThinkingLevels, BASE_THINKING_LEVELS } from "open-sse/providers/thinkingLevels.js"; import { getThinkingLevels, BASE_THINKING_LEVELS } from "open-sse/providers/thinkingLevels.js";
@@ -641,12 +641,14 @@ export default function ProviderDetailPage() {
} }
}; };
const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias, caps) => { // `transport` pins a realtime STT dispatch marker (shared whitelist
// STT_TRANSPORT_META); the API only honours it on type "stt" records.
const handleAddCustomModel = async (modelId, type = "llm", providerAliasOverride = providerStorageAlias, caps, transport) => {
try { try {
const res = await fetch("/api/models/custom", { const res = await fetch("/api/models/custom", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type, ...(caps ? { caps } : {}) }), body: JSON.stringify({ providerAlias: providerAliasOverride, id: modelId, type, ...(caps ? { caps } : {}), ...(transport ? { transport } : {}) }),
}); });
if (res.ok) { if (res.ok) {
await fetchCustomModels(); await fetchCustomModels();
@@ -2526,6 +2528,13 @@ export default function ProviderDetailPage() {
onSuccess={handleOAuthSuccess} onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)} onClose={() => setShowOAuthModal(false)}
/> />
) : providerId === "zed" ? (
<ZedAuthModal
isOpen={showOAuthModal}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "gitlab" ? ( ) : providerId === "gitlab" ? (
<GitLabAuthModal <GitLabAuthModal
isOpen={showOAuthModal} isOpen={showOAuthModal}
@@ -2596,8 +2605,16 @@ export default function ProviderDetailPage() {
providerAlias={providerStorageAlias} providerAlias={providerStorageAlias}
initialModelId={editingModel?.id || ""} initialModelId={editingModel?.id || ""}
initialCaps={editingModel?.caps || null} initialCaps={editingModel?.caps || null}
onSave={async (modelId, caps) => { onSave={async (modelId, caps, transport) => {
await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps); // caps.stt is a UI-only flag; the API accepts transports only on
// type "stt" records, so the save derives the type from it.
await handleAddCustomModel(
modelId,
caps?.stt ? "stt" : "llm",
providerStorageAlias,
caps,
transport
);
setShowAddCustomModel(false); setShowAddCustomModel(false);
setEditingModel(null); setEditingModel(null);
}} }}

View File

@@ -100,6 +100,28 @@ function getCodexResetCreditCount(quota) {
return Number.isFinite(count) ? Math.max(0, count) : 0; return Number.isFinite(count) ? Math.max(0, count) : 0;
} }
const CLAUDE_RESET_LIMIT_NAMES = {
five_hour: "session",
seven_day: "weekly",
seven_day_overage_included: "weekly",
seven_day_opus: "Opus weekly",
seven_day_sonnet: "Sonnet weekly",
};
function formatClaudeResetClears(clears) {
const names = [...new Set((clears || []).map((c) => CLAUDE_RESET_LIMIT_NAMES[c]).filter(Boolean))];
return names.length ? `${names.join(" + ")} limits` : "limits";
}
function claudeGrantStatus(grant) {
if (grant.resetsLeft <= 0) return "used";
if (grant.paused) return "paused";
if (grant.endsAt && new Date(grant.endsAt).getTime() <= Date.now()) return "expired";
if (grant.usableNow) return "usable now";
if (grant.startsAt && new Date(grant.startsAt).getTime() > Date.now()) return "not started";
return grant.useRequiresLimit ? "at limit only" : "unavailable";
}
function providerLabel(providerId) { function providerLabel(providerId) {
return AI_PROVIDERS[providerId]?.name || providerId; return AI_PROVIDERS[providerId]?.name || providerId;
} }
@@ -308,30 +330,41 @@ export default function ProviderLimits() {
const handleResetCodexLimit = useCallback( const handleResetCodexLimit = useCallback(
async (connectionId, provider) => { async (connectionId, provider) => {
if (provider !== "codex" || resettingLimitId) return; if ((provider !== "codex" && provider !== "claude") || resettingLimitId) return;
setResettingLimitId(connectionId); setResettingLimitId(connectionId);
setErrors((prev) => ({ ...prev, [connectionId]: null })); setErrors((prev) => ({ ...prev, [connectionId]: null }));
try { try {
const response = await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" }); const response = provider === "claude"
? await fetch(`/api/usage/${connectionId}/claude-reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ grantId: quotaData[connectionId]?.raw?.resetCredits?.nextGrantId }),
})
: await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
throw new Error(result.message || result.error || result.code || "Failed to reset Codex limit"); throw new Error(result.message || result.error || result.code || "Failed to reset limit");
} }
await fetchQuota(connectionId, provider); await fetchQuota(connectionId, provider, { force: true });
setLastUpdated(new Date()); setLastUpdated(new Date());
} catch (error) { } catch (error) {
setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset Codex limit" })); setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset limit" }));
} finally { } finally {
setResettingLimitId(null); setResettingLimitId(null);
} }
}, },
[fetchQuota, resettingLimitId], [fetchQuota, resettingLimitId, quotaData],
); );
// Claude grants already arrive with the usage read; no extra fetch
const handleViewClaudeResets = useCallback((connection, resetCredits) => {
setResetCreditsState({ connection, loading: false, error: null, data: { kind: "claude", ...resetCredits } });
}, []);
const handleViewCodexResetCredits = useCallback(async (connection) => { const handleViewCodexResetCredits = useCallback(async (connection) => {
setResetCreditsState({ connection, loading: true, error: null, data: null }); setResetCreditsState({ connection, loading: true, error: null, data: null });
try { try {
@@ -1055,6 +1088,8 @@ export default function ProviderLimits() {
// Use table layout for all providers // Use table layout for all providers
const isInactive = conn.isActive === false; const isInactive = conn.isActive === false;
const isCodex = conn.provider === "codex"; const isCodex = conn.provider === "codex";
const claudeReset = conn.provider === "claude" ? quota?.raw?.resetCredits : null;
const resetLabel = isCodex ? "Codex reset credit" : "Claude limit reset";
const resetCreditCount = getCodexResetCreditCount(quota); const resetCreditCount = getCodexResetCreditCount(quota);
const isResettingLimit = resettingLimitId === conn.id; const isResettingLimit = resettingLimitId === conn.id;
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit; const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit;
@@ -1140,13 +1175,15 @@ export default function ProviderLimits() {
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{isCodex && ( {(isCodex || claudeReset) && (
<> <>
<Tooltip <Tooltip
text={ text={
resetCreditCount > 0 resetCreditCount > 0
? `Use one Codex reset credit. Available: ${resetCreditCount}` ? claudeReset
: "No Codex reset credits available" ? `Use your reset now (${resetCreditCount} left, use by ${formatCreditDate(claudeReset.expiresAt)}) · refills ${formatClaudeResetClears(claudeReset.clears)}`
: `Use one ${resetLabel}. Available: ${resetCreditCount}`
: `No ${resetLabel}s available`
} }
> >
<button <button
@@ -1155,8 +1192,8 @@ export default function ProviderLimits() {
disabled={resetCreditCount <= 0 || isLoading || rowBusy} disabled={resetCreditCount <= 0 || isLoading || rowBusy}
aria-label={ aria-label={
resetCreditCount > 0 resetCreditCount > 0
? `Use one Codex reset credit. ${resetCreditCount} available.` ? `Use one ${resetLabel}. ${resetCreditCount} available.`
: "No Codex reset credits available" : `No ${resetLabel}s available`
} }
className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${ className={`flex h-8 min-w-10 items-center justify-center gap-1 rounded-lg border px-2 text-[11px] font-medium tabular-nums transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60 disabled:cursor-not-allowed disabled:opacity-60 ${
resetCreditCount > 0 resetCreditCount > 0
@@ -1170,12 +1207,12 @@ export default function ProviderLimits() {
<span>{resetCreditCount}</span> <span>{resetCreditCount}</span>
</button> </button>
</Tooltip> </Tooltip>
<Tooltip text="View Codex reset credit expiry"> <Tooltip text={isCodex ? "View Codex reset credit expiry" : "View Claude Code reset expiry"}>
<button <button
type="button" type="button"
onClick={() => handleViewCodexResetCredits(conn)} onClick={() => (isCodex ? handleViewCodexResetCredits(conn) : handleViewClaudeResets(conn, claudeReset))}
disabled={isLoading || rowBusy} disabled={isLoading || rowBusy}
aria-label="View Codex reset credit expiry" aria-label={isCodex ? "View Codex reset credit expiry" : "View Claude Code reset expiry"}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-muted transition-colors hover:bg-black/5 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/5" className="flex h-8 w-8 items-center justify-center rounded-lg border border-black/10 text-text-muted transition-colors hover:bg-black/5 hover:text-primary disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:hover:bg-white/5"
> >
<span className="material-symbols-outlined text-[17px]">schedule</span> <span className="material-symbols-outlined text-[17px]">schedule</span>
@@ -1457,8 +1494,10 @@ export default function ProviderLimits() {
await handleResetCodexLimit(connection.id, connection.provider); await handleResetCodexLimit(connection.id, connection.provider);
setResetConfirmState(null); setResetConfirmState(null);
}} }}
title="Reset Codex limit?" title={resetConfirmState?.connection?.provider === "claude" ? "Reset Claude limits?" : "Reset Codex limit?"}
message={`Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`} message={resetConfirmState?.connection?.provider === "claude"
? `Refills your ${formatClaudeResetClears(quotaData[resetConfirmState.connection.id]?.raw?.resetCredits?.clears)} now for ${getConnectionLabel(resetConfirmState.connection) || "this account"} · your weekly reset day stays ${formatCreditDate(quotaData[resetConfirmState.connection.id]?.raw?.resetCredits?.weeklyResetsAt)}. This cannot be undone. Resets left: ${resetConfirmState.resetCreditCount ?? 0}.`
: `Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`}
confirmText="Reset limit" confirmText="Reset limit"
cancelText="Cancel" cancelText="Cancel"
variant="danger" variant="danger"
@@ -1470,9 +1509,11 @@ export default function ProviderLimits() {
<div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-black/15 bg-white shadow-2xl ring-1 ring-black/10 dark:border-white/15 dark:bg-neutral-950 dark:ring-white/10"> <div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-black/15 bg-white shadow-2xl ring-1 ring-black/10 dark:border-white/15 dark:bg-neutral-950 dark:ring-white/10">
<div className="flex items-start justify-between gap-3 border-b border-black/10 bg-black/[0.03] px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]"> <div className="flex items-start justify-between gap-3 border-b border-black/10 bg-black/[0.03] px-4 py-3 dark:border-white/10 dark:bg-white/[0.04]">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-base font-semibold text-text-primary">Codex Reset Credit Expiry</h3> <h3 className="text-base font-semibold text-text-primary">
{resetCreditsState.data?.kind === "claude" ? "Claude Code Limit Resets" : "Codex Reset Credit Expiry"}
</h3>
<p className="mt-0.5 truncate text-xs text-text-muted"> <p className="mt-0.5 truncate text-xs text-text-muted">
{getConnectionLabel(resetCreditsState.connection) || "Codex account"} {getConnectionLabel(resetCreditsState.connection) || (resetCreditsState.data?.kind === "claude" ? "Claude account" : "Codex account")}
</p> </p>
</div> </div>
<button <button
@@ -1495,6 +1536,42 @@ export default function ProviderLimits() {
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300"> <div className="rounded-xl border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-600 dark:text-red-300">
{resetCreditsState.error} {resetCreditsState.error}
</div> </div>
) : resetCreditsState.data?.kind === "claude" && resetCreditsState.data.grants?.length ? (
<div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
<span>{resetCreditsState.data.availableCount ?? 0} reset{resetCreditsState.data.availableCount === 1 ? "" : "s"} left</span>
<span>Weekly reset day: {formatCreditDate(resetCreditsState.data.weeklyResetsAt)}</span>
</div>
<div className="overflow-x-auto rounded-xl border border-black/10 dark:border-white/10">
<table className="w-full min-w-[560px] text-left text-sm">
<thead className="bg-black/[0.03] text-xs uppercase tracking-wide text-text-muted dark:bg-white/[0.04]">
<tr>
<th className="px-3 py-2 font-medium">Reset</th>
<th className="px-3 py-2 font-medium">Left</th>
<th className="px-3 py-2 font-medium">Refills</th>
<th className="px-3 py-2 font-medium">Use By</th>
<th className="px-3 py-2 font-medium">Remaining</th>
</tr>
</thead>
<tbody>
{(resetCreditsState.data.grants || []).map((grant) => (
<tr key={grant.id} className="border-t border-black/5 dark:border-white/5">
<td className="px-3 py-2">
<div className="text-text-primary">{grant.label || grant.id}</div>
<span className="mt-1 inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{claudeGrantStatus(grant)}
</span>
</td>
<td className="whitespace-nowrap px-3 py-2 font-medium tabular-nums text-text-primary">{grant.resetsLeft} / {grant.resetsTotal}</td>
<td className="px-3 py-2 text-text-muted">{formatClaudeResetClears(grant.clears)}</td>
<td className="px-3 py-2 text-text-primary">{formatCreditDate(grant.endsAt)}</td>
<td className="whitespace-nowrap px-3 py-2 font-medium text-text-primary">{formatTimeRemaining(grant.endsAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : resetCreditsState.data?.credits?.length ? ( ) : resetCreditsState.data?.credits?.length ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]"> <div className="flex items-center justify-between rounded-xl border border-black/10 bg-black/[0.02] px-3 py-2 text-xs text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
@@ -1530,7 +1607,7 @@ export default function ProviderLimits() {
</div> </div>
) : ( ) : (
<div className="rounded-xl border border-black/10 bg-black/[0.02] px-3 py-8 text-center text-sm text-text-muted dark:border-white/10 dark:bg-white/[0.03]"> <div className="rounded-xl border border-black/10 bg-black/[0.02] px-3 py-8 text-center text-sm text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
No reset credit details returned for this account. {resetCreditsState.data?.kind === "claude" ? "No limit resets available for this account." : "No reset credit details returned for this account."}
</div> </div>
)} )}
</div> </div>

View File

@@ -2,7 +2,8 @@
import { Suspense, useState } from "react"; import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; import { RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import UsageStats from "@/shared/components/UsageStats";
import RequestDetailsTab from "./components/RequestDetailsTab"; import RequestDetailsTab from "./components/RequestDetailsTab";
const PERIODS = [ const PERIODS = [

View File

@@ -151,11 +151,16 @@ export async function POST(request) {
// Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix // Normalize ANTHROPIC_BASE_URL to ensure /v1 suffix
if (env.ANTHROPIC_BASE_URL) { if (env.ANTHROPIC_BASE_URL) {
env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1") env.ANTHROPIC_BASE_URL = env.ANTHROPIC_BASE_URL.endsWith("/v1")
? env.ANTHROPIC_BASE_URL ? env.ANTHROPIC_BASE_URL
: `${env.ANTHROPIC_BASE_URL}/v1`; : `${env.ANTHROPIC_BASE_URL}/v1`;
} }
// Keep an existing token (real key or earlier config); only add when absent — Reset clears it.
if (currentSettings.env?.ANTHROPIC_AUTH_TOKEN) {
delete env.ANTHROPIC_AUTH_TOKEN;
}
// Merge new env with existing settings // Merge new env with existing settings
const newSettings = { const newSettings = {
...currentSettings, ...currentSettings,

View File

@@ -0,0 +1,123 @@
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
export const dynamic = "force-dynamic";
const getCodexDir = () => path.join(os.homedir(), ".codex");
const isValidProfileName = (name) => {
return typeof name === "string" && /^[a-zA-Z0-9_-]+$/.test(name) && name.length <= 64 && name.toLowerCase() !== "config";
};
const isValidModel = (model) => {
return typeof model === "string" && model.trim().length > 0 && model.length <= 256 && !/[\r\n"]/.test(model);
};
// GET - List all custom profiles from ~/.codex/*.config.toml
export async function GET() {
try {
const codexDir = getCodexDir();
let files = [];
try {
files = await fs.readdir(codexDir);
} catch (err) {
if (err.code === "ENOENT") return NextResponse.json({ profiles: [] });
throw err;
}
const profileFiles = files.filter(
(file) => file.endsWith(".config.toml") && file !== "config.toml"
);
const profiles = await Promise.all(
profileFiles.map(async (file) => {
const name = file.replace(/\.config\.toml$/, "");
try {
const content = await fs.readFile(path.join(codexDir, file), "utf-8");
const match = content.match(/^\s*model\s*=\s*(["'])([^"\n]+)\1/m);
return {
name,
model: match ? match[2] : "",
command: `codex -p ${name}`,
};
} catch {
return { name, model: "", command: `codex -p ${name}` };
}
})
);
profiles.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({ profiles });
} catch (error) {
console.error("Error reading codex profiles:", error);
return NextResponse.json({ error: "Failed to read profiles" }, { status: 500 });
}
}
// POST - Create or update a profile ~/.codex/<name>.config.toml
export async function POST(request) {
try {
const { name, model } = await request.json();
const cleanName = typeof name === "string" ? name.trim().toLowerCase() : "";
if (!isValidProfileName(cleanName)) {
return NextResponse.json(
{ error: "Profile name can only contain alphanumeric characters, dashes, underscores and cannot be 'config'" },
{ status: 400 }
);
}
const cleanModel = typeof model === "string" ? model.trim() : "";
if (!isValidModel(cleanModel)) {
return NextResponse.json({ error: "Invalid model name" }, { status: 400 });
}
const codexDir = getCodexDir();
await fs.mkdir(codexDir, { recursive: true });
const filePath = path.join(codexDir, `${cleanName}.config.toml`);
const content = `# codex -p ${cleanName}\nmodel = "${cleanModel}"\nmodel_provider = "9router"\n`;
await fs.writeFile(filePath, content, "utf-8");
return NextResponse.json({
success: true,
profile: {
name: cleanName,
model: cleanModel,
command: `codex -p ${cleanName}`,
},
});
} catch (error) {
console.error("Error saving codex profile:", error);
return NextResponse.json({ error: "Failed to save profile" }, { status: 500 });
}
}
// DELETE - Remove a profile ~/.codex/<name>.config.toml
export async function DELETE(request) {
try {
const { name } = await request.json();
const cleanName = typeof name === "string" ? name.trim().toLowerCase() : "";
if (!isValidProfileName(cleanName)) {
return NextResponse.json({ error: "Invalid profile name" }, { status: 400 });
}
const filePath = path.join(getCodexDir(), `${cleanName}.config.toml`);
try {
await fs.unlink(filePath);
} catch (err) {
if (err.code === "ENOENT") {
return NextResponse.json({ error: "Profile not found" }, { status: 404 });
}
throw err;
}
return NextResponse.json({ success: true, message: `Profile ${cleanName} deleted` });
} catch (error) {
console.error("Error deleting codex profile:", error);
return NextResponse.json({ error: "Failed to delete profile" }, { status: 500 });
}
}

View File

@@ -1,5 +1,3 @@
"use server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { exec } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
@@ -8,6 +6,8 @@ import path from "path";
import os from "os"; import os from "os";
import { parseTOML, stringifyTOML } from "confbox"; import { parseTOML, stringifyTOML } from "confbox";
export const dynamic = "force-dynamic";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const getCodexDir = () => path.join(os.homedir(), ".codex"); const getCodexDir = () => path.join(os.homedir(), ".codex");

View File

@@ -18,10 +18,20 @@ const getHermesEnvPath = () => path.join(getHermesDir(), ".env");
// Match top-level "model:" block (until next non-indented, non-empty line) // Match top-level "model:" block (until next non-indented, non-empty line)
const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m; const MODEL_BLOCK_RE = /^model:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
const DELEGATION_BLOCK_RE = /^delegation:[ \t]*\r?\n((?:[ \t]+.*\r?\n?|[ \t]*\r?\n)*)/m;
// "auxiliary:" block; children are 2-space-indented role keys with 4+-space fields
const AUX_BLOCK_RE = /^auxiliary:[ \t]*\r?\n((?:(?:[ \t]+.*\r?\n?)|(?:[ \t]*\r?\n))*)/m;
const auxRoleRe = (role) => new RegExp(`^ ${role}:[ \\t]*\\r?\\n(?:(?:[ \\t]{4,}.*\\r?\\n?)|(?:[ \\t]*\\r?\\n))*`, "m");
const buildModelBlock = (model, baseUrl) => const buildModelBlock = (model, baseUrl) =>
`model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`; `model:\n default: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
const buildDelegationBlock = (model, baseUrl) =>
`delegation:\n model: "${model}"\n provider: "custom"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
const buildAuxRoleBlock = (role, model, baseUrl) =>
` ${role}:\n provider: "custom"\n model: "${model}"\n base_url: "${baseUrl}"\n api_key: \${OPENAI_API_KEY}\n`;
// Parse current model block back to fields (best-effort, simple key:value) // Parse current model block back to fields (best-effort, simple key:value)
const parseModelBlock = (yaml) => { const parseModelBlock = (yaml) => {
const match = yaml.match(MODEL_BLOCK_RE); const match = yaml.match(MODEL_BLOCK_RE);
@@ -44,6 +54,60 @@ const upsertModelBlock = (yaml, newBlock) => {
return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock; return yaml.length > 0 ? `${newBlock}\n${yaml}` : newBlock;
}; };
const upsertDelegationBlock = (yaml, newBlock) => {
if (DELEGATION_BLOCK_RE.test(yaml)) return yaml.replace(DELEGATION_BLOCK_RE, newBlock);
return yaml.endsWith("\n") || yaml.length === 0 ? `${yaml}${newBlock}` : `${yaml}\n${newBlock}`;
};
const removeDelegationBlock = (yaml) => yaml.replace(DELEGATION_BLOCK_RE, "");
const upsertAuxRole = (yaml, role, roleBlock) => {
const re = auxRoleRe(role);
const m = yaml.match(AUX_BLOCK_RE);
if (!m) {
const block = `auxiliary:\n${roleBlock}`;
return yaml.endsWith("\n") || yaml.length === 0 ? `${yaml}${block}` : `${yaml}\n${block}`;
}
const body = re.test(m[1]) ? m[1].replace(re, roleBlock) : `${m[1]}${roleBlock}`;
return yaml.replace(AUX_BLOCK_RE, `auxiliary:\n${body}`);
};
const removeAuxRole = (yaml, role) => {
const m = yaml.match(AUX_BLOCK_RE);
if (!m) return yaml;
const body = m[1].replace(auxRoleRe(role), "");
if (body.trim() === "") return yaml.replace(AUX_BLOCK_RE, "");
return yaml.replace(AUX_BLOCK_RE, `auxiliary:\n${body}`);
};
// role -> { model, provider, base_url } for every entry under "auxiliary:"
const parseAuxRoles = (yaml) => {
const m = yaml.match(AUX_BLOCK_RE);
if (!m) return {};
const roles = {};
const subRe = /^ ([A-Za-z0-9_]+):[ \t]*\r?\n((?:(?:[ \t]{4,}.*\r?\n?)|(?:[ \t]*\r?\n))*)/gm;
let sm;
while ((sm = subRe.exec(m[1]))) {
const get = (key) => {
const km = sm[2].match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m"));
return km ? km[1].trim() : null;
};
roles[sm[1]] = { model: get("model"), provider: get("provider"), base_url: get("base_url") };
}
return roles;
};
const parseDelegationBlock = (yaml) => {
const match = yaml.match(DELEGATION_BLOCK_RE);
if (!match) return null;
const body = match[1] || "";
const get = (key) => {
const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*["']?([^"'\\r\\n]+)["']?`, "m"));
return m ? m[1].trim() : null;
};
return { model: get("model"), provider: get("provider"), base_url: get("base_url") };
};
const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, ""); const removeModelBlock = (yaml) => yaml.replace(MODEL_BLOCK_RE, "").replace(/^\n+/, "");
// .env helpers — upsert/remove single KEY=VALUE line // .env helpers — upsert/remove single KEY=VALUE line
@@ -107,10 +171,12 @@ export async function GET() {
} }
const yaml = await readConfigYaml(); const yaml = await readConfigYaml();
const model = parseModelBlock(yaml); const model = parseModelBlock(yaml);
const delegation = parseDelegationBlock(yaml);
const auxiliary = parseAuxRoles(yaml);
return NextResponse.json({ return NextResponse.json({
installed: true, installed: true,
settings: { model }, settings: { model, delegation, auxiliary },
has9Router: has9RouterConfig(model), has9Router: has9RouterConfig(model) || has9RouterConfig(delegation) || Object.values(auxiliary).some(has9RouterConfig),
configPath: getHermesConfigPath(), configPath: getHermesConfigPath(),
}); });
} catch (error) { } catch (error) {
@@ -121,8 +187,14 @@ export async function GET() {
export async function POST(request) { export async function POST(request) {
try { try {
const { baseUrl, apiKey, model } = await request.json(); const { baseUrl, apiKey, model, selections } = await request.json();
if (!baseUrl || !model) { // selections: [{role, model}] — "default" plus any auxiliary/delegation slots.
// Legacy callers (CLI quick setup) send a bare `model` → treat as default role.
const sel = Array.isArray(selections) && selections.some((s) => s?.role && s?.model)
? selections.filter((s) => s?.role && s?.model)
: model ? [{ role: "default", model }] : [];
const defaultSel = sel.find((s) => s.role === "default");
if (!baseUrl || !defaultSel) {
return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 });
} }
@@ -131,9 +203,17 @@ export async function POST(request) {
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
// Update config.yaml — replace/insert model: block, keep everything else // Update config.yaml — upsert each role block, keep everything else
const existingYaml = await readConfigYaml(); let newYaml = await readConfigYaml();
const newYaml = upsertModelBlock(existingYaml, buildModelBlock(model, normalizedBaseUrl)); for (const { role, model: roleModel } of sel) {
if (role === "default") {
newYaml = upsertModelBlock(newYaml, buildModelBlock(roleModel, normalizedBaseUrl));
} else if (role === "delegation") {
newYaml = upsertDelegationBlock(newYaml, buildDelegationBlock(roleModel, normalizedBaseUrl));
} else {
newYaml = upsertAuxRole(newYaml, role, buildAuxRoleBlock(role, roleModel, normalizedBaseUrl));
}
}
await fs.writeFile(getHermesConfigPath(), newYaml); await fs.writeFile(getHermesConfigPath(), newYaml);
// Update .env — upsert OPENAI_API_KEY only when caller provides one // Update .env — upsert OPENAI_API_KEY only when caller provides one
@@ -166,9 +246,15 @@ export async function DELETE() {
} }
throw error; throw error;
} }
const newYaml = removeModelBlock(yaml); let newYaml = removeModelBlock(yaml);
newYaml = removeDelegationBlock(newYaml);
// Only drop auxiliary entries we manage (custom provider, any base URL — covers tunnels)
for (const [role, cfg] of Object.entries(parseAuxRoles(yaml))) {
if (cfg?.provider === "custom") newYaml = removeAuxRole(newYaml, role);
}
newYaml = newYaml.replace(/^\n+/, "");
await fs.writeFile(configPath, newYaml); await fs.writeFile(configPath, newYaml);
return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model block removed` }); return NextResponse.json({ success: true, message: `${PROVIDER_NAME} model blocks removed` });
} catch (error) { } catch (error) {
console.log("Error resetting hermes settings:", error); console.log("Error resetting hermes settings:", error);
return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 }); return NextResponse.json({ error: "Failed to reset hermes settings" }, { status: 500 });

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models"; import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models";
import { CAPACITY_META } from "@/shared/constants/models"; import { CAPACITY_META, isSttTransport } from "@/shared/constants/models";
import { invalidateUserCaps } from "@/lib/modelCaps/userCaps.js"; import { invalidateUserCaps } from "@/lib/modelCaps/userCaps.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,6 +15,16 @@ function sanitizeCaps(caps) {
return Object.keys(clean).length ? clean : null; return Object.keys(clean).length ? clean : null;
} }
// Accepted STT transport markers live in the shared whitelist
// (src/shared/constants/models STT_TRANSPORT_META) — the dashboard transport
// select and this validator must agree on one set, so neither owns a copy.
// Unknown or mistyped values are silently dropped, the same policy
// sanitizeCaps applies to capability keys.
function sanitizeTransport(transport, type) {
if (type !== "stt" || !isSttTransport(transport)) return null;
return transport.trim();
}
// GET /api/models/custom - List all custom models // GET /api/models/custom - List all custom models
export async function GET() { export async function GET() {
try { try {
@@ -29,12 +39,20 @@ export async function GET() {
// POST /api/models/custom - Add custom model // POST /api/models/custom - Add custom model
export async function POST(request) { export async function POST(request) {
try { try {
const { providerAlias, id, type, name, caps } = await request.json(); const { providerAlias, id, type, name, caps, transport } = await request.json();
if (!providerAlias || !id) { if (!providerAlias || !id) {
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 }); return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
} }
const cleanCaps = sanitizeCaps(caps); const cleanCaps = sanitizeCaps(caps);
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name, ...(cleanCaps ? { caps: cleanCaps } : {}) }); const cleanTransport = sanitizeTransport(transport, type || "llm");
const added = await addCustomModel({
providerAlias,
id,
type: type || "llm",
name,
...(cleanCaps ? { caps: cleanCaps } : {}),
...(cleanTransport ? { transport: cleanTransport } : {}),
});
invalidateUserCaps(); invalidateUserCaps();
return NextResponse.json({ success: true, added }); return NextResponse.json({ success: true, added });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { readZedIdeCredentials } from "@/lib/oauth/utils/zedCredentials";
/**
* GET /api/oauth/zed/auto-import
* Read the signed-in Zed IDE session from the OS keyring/keychain.
*
* Linux: secret-tool (url=https://zed.dev, label zed-github-account)
* macOS: Keychain internet password (server=https://zed.dev)
* Windows: Credential Manager target zed:url=https://zed.dev
*
* Also loads system_id from Zed's local kv_store when present.
*/
export async function GET() {
try {
const result = await readZedIdeCredentials();
if (!result.found) {
return NextResponse.json({
found: false,
error: result.error || "Zed IDE credentials not found",
credentialsUrl: result.credentialsUrl || null,
});
}
return NextResponse.json({
found: true,
userId: result.userId,
accessToken: result.accessToken,
systemId: result.systemId,
credentialsUrl: result.credentialsUrl,
});
} catch (error) {
console.log("Zed auto-import error:", error);
return NextResponse.json(
{ found: false, error: error.message || "Failed to read Zed credentials" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { createProviderConnection } from "@/models";
import {
fetchZedAuthenticatedUser,
resolveZedOrganizationId,
} from "open-sse/shared/zedAuth.js";
/**
* POST /api/oauth/zed/import
* Validate + save a Zed session (typically from IDE auto-import).
*
* Body: { accessToken, userId, systemId? }
*/
export async function POST(request) {
try {
const body = await request.json();
const accessToken = typeof body?.accessToken === "string" ? body.accessToken.trim() : "";
const userId = body?.userId != null ? String(body.userId).trim() : "";
const systemId =
(typeof body?.systemId === "string" && body.systemId.trim()) || randomUUID();
if (!accessToken) {
return NextResponse.json({ error: "Access token is required" }, { status: 400 });
}
if (!userId) {
return NextResponse.json({ error: "User id is required" }, { status: 400 });
}
const credentials = {
accessToken,
providerSpecificData: { userId, systemId },
};
let userInfo = null;
try {
userInfo = await fetchZedAuthenticatedUser(credentials);
} catch (err) {
return NextResponse.json(
{ error: err.message || "Zed token validation failed" },
{ status: 401 },
);
}
const organizationId = resolveZedOrganizationId(credentials, userInfo);
const email = userInfo?.email || null;
const displayName =
userInfo?.name || userInfo?.display_name || userInfo?.username || `Zed ${userId}`;
const connection = await createProviderConnection({
provider: "zed",
authType: "oauth",
accessToken,
refreshToken: null,
expiresAt: null,
email,
displayName,
providerSpecificData: {
authMethod: "imported",
userId,
systemId,
organizationId: organizationId || "",
},
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
},
});
} catch (error) {
console.log("Zed import token error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -302,6 +302,12 @@ const PROVIDER_MODELS_CONFIG = {
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"), nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"), assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"),
"vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"), "vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"),
// OpenAI-compatible aggregators.
tokenharbor: createOpenAIModelsConfig("https://tokenharbor.ai/v1/models"),
dahl: createOpenAIModelsConfig("https://inference.dahl.global/v1/models"),
atria: createOpenAIModelsConfig("https://api.atria-asi.ai/v1/models"),
agnes: createOpenAIModelsConfig("https://apihub.agnes-ai.com/v1/models"),
bai: createOpenAIModelsConfig("https://api.b.ai/v1/models"),
kimchi: { kimchi: {
customResolver: async (connection) => { customResolver: async (connection) => {
const result = await resolveKimchiModels({ const result = await resolveKimchiModels({

View File

@@ -696,6 +696,15 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); const res = await fetchWithConnectionProxy("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
} }
case "tokenharbor":
case "dahl":
case "atria":
case "agnes":
case "bai": {
const cfg = PROVIDERS[connection.provider];
const res = await fetchWithConnectionProxy(cfg.validateUrl, { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
case "ollama": { case "ollama": {
const res = await fetch("https://ollama.com/api/tags", { headers: { Authorization: `Bearer ${connection.apiKey}` } }); const res = await fetch("https://ollama.com/api/tags", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; return { valid: res.ok, error: res.ok ? null : "Invalid API key" };

View File

@@ -150,6 +150,9 @@ export async function POST(request) {
providerSpecificData: mergedProviderSpecificData, providerSpecificData: mergedProviderSpecificData,
isActive: true, isActive: true,
testStatus: testStatus || "unknown", testStatus: testStatus || "unknown",
// POST with an id is an explicit edit of that connection; without one, a
// name collision is refused rather than silently overwriting a key. #4311
allowOverwrite: body.id ? true : (body.allowOverwrite === true || body.overwrite === true),
}); });
// Hide sensitive fields // Hide sensitive fields
@@ -158,6 +161,12 @@ export async function POST(request) {
return NextResponse.json({ connection: result }, { status: 201 }); return NextResponse.json({ connection: result }, { status: 201 });
} catch (error) { } catch (error) {
if (error?.code === "PROVIDER_NAME_CONFLICT") {
return NextResponse.json(
{ error: error.message, code: error.code, existingId: error.existingId, existingName: error.existingName },
{ status: 409 }
);
}
console.log("Error creating provider:", error); console.log("Error creating provider:", error);
return NextResponse.json({ error: "Failed to create provider" }, { status: 500 }); return NextResponse.json({ error: "Failed to create provider" }, { status: 500 });
} }

View File

@@ -21,6 +21,13 @@ export const FILTERS = {
.filter((m) => (m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id)) && !DEAD_FREE_OPENCODE_MODELS.has(m.id)) .filter((m) => (m.id?.endsWith("-free") || KNOWN_FREE_OPENCODE_MODELS.includes(m.id)) && !DEAD_FREE_OPENCODE_MODELS.has(m.id))
.map((m) => ({ id: m.id, name: m.id })), .map((m) => ({ id: m.id, name: m.id })),
// Go subscription catalogue — every /models id is selectable; the endpoint lane
// per model is resolved by the family regex (see open-sse/providers/models/helpers.js)
"opencode-go": (models) =>
(Array.isArray(models) ? models : [])
.filter((m) => typeof m?.id === "string")
.map((m) => ({ id: m.id, name: m.id })),
// models.dev returns a large catalog; keep only mimo models // models.dev returns a large catalog; keep only mimo models
"mimo-free": (models) => "mimo-free": (models) =>
(Array.isArray(models) ? models : []) (Array.isArray(models) ? models : [])

View File

@@ -0,0 +1,40 @@
// Ensure proxyFetch is loaded to patch globalThis.fetch
import "open-sse/index.js";
import { getProviderConnectionById } from "@/lib/localDb";
import { consumeClaudeResetGrant } from "open-sse/services/usage.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { refreshAndUpdateCredentials } from "../route.js";
// Spend one free Claude "limit reset" grant (irreversible)
export async function POST(request, { params }) {
try {
const { connectionId } = await params;
const { grantId } = await request.json().catch(() => ({}));
let connection = await getProviderConnectionById(connectionId);
if (!connection) return Response.json({ error: "Connection not found" }, { status: 404 });
if (connection.provider !== "claude" || connection.authType !== "oauth") {
return Response.json({ error: "Limit reset is only available for Claude OAuth connections." }, { status: 400 });
}
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
const proxyOptions = {
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
connectionNoProxy: proxyConfig.connectionNoProxy || "",
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
strictProxy: false,
};
({ connection } = await refreshAndUpdateCredentials(connection, false, proxyOptions));
const result = await consumeClaudeResetGrant(connection.accessToken, grantId, proxyOptions);
if (result.ok) return Response.json(result);
const status = result.status >= 400 && result.status < 500 ? result.status : 409;
return Response.json({ ...result, message: result.message || `Reset not applied: ${result.reason || result.result || "unknown"}` }, { status });
} catch (error) {
console.warn(`[Claude Reset] ${error.message}`);
return Response.json({ error: error.message }, { status: 500 });
}
}

View File

@@ -45,6 +45,7 @@ const ALWAYS_PROTECTED = [
"/api/version/update", "/api/version/update",
"/api/oauth/cursor/auto-import", "/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import", "/api/oauth/kiro/auto-import",
"/api/oauth/zed/auto-import",
]; ];
// Require auth, but allow through if requireLogin is disabled // Require auth, but allow through if requireLogin is disabled
@@ -81,6 +82,7 @@ const LOCAL_ONLY_PATHS = [
"/api/tunnel/disable", "/api/tunnel/disable",
"/api/oauth/cursor/auto-import", "/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import", "/api/oauth/kiro/auto-import",
"/api/oauth/zed/auto-import",
"/api/auth/reset-password", "/api/auth/reset-password",
"/api/headroom/start", "/api/headroom/start",
"/api/headroom/stop", "/api/headroom/stop",

View File

@@ -29,11 +29,10 @@ export async function getCustomModels() {
return Object.values(all); return Object.values(all);
} }
// Atomic upsert inside transaction to prevent duplicate races. // Re-adding an existing model updates caps/name/transport without resetting omitted fields.
// Re-adding an existing model updates caps/name without resetting omitted fields.
// `caps` is merged key-by-key so a partial edit (one toggled capacity) keeps the // `caps` is merged key-by-key so a partial edit (one toggled capacity) keeps the
// assertions already stored for the other capacities. // assertions already stored for the other capacities.
export async function addCustomModel({ providerAlias, id, type = "llm", name, caps }) { export async function addCustomModel({ providerAlias, id, type = "llm", name, caps, transport }) {
const k = customKey(providerAlias, id, type); const k = customKey(providerAlias, id, type);
const db = await getAdapter(); const db = await getAdapter();
let added = false; let added = false;
@@ -45,11 +44,12 @@ export async function addCustomModel({ providerAlias, id, type = "llm", name, ca
...prev, ...prev,
...(name ? { name } : {}), ...(name ? { name } : {}),
...(caps ? { caps: { ...(prev.caps || {}), ...caps } } : {}), ...(caps ? { caps: { ...(prev.caps || {}), ...caps } } : {}),
...(transport ? { transport } : {}),
}; };
db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]); db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]);
return; return;
} }
const value = stringifyJson({ providerAlias, id, type, name: name || id, ...(caps ? { caps } : {}) }); const value = stringifyJson({ providerAlias, id, type, name: name || id, ...(caps ? { caps } : {}), ...(transport ? { transport } : {}) });
db.run(`INSERT INTO kv(scope, key, value) VALUES('customModels', ?, ?)`, [k, value]); db.run(`INSERT INTO kv(scope, key, value) VALUES('customModels', ?, ?)`, [k, value]);
added = true; added = true;
}); });

View File

@@ -108,7 +108,15 @@ export async function getProviderConnectionById(id) {
return rowToConn(row); return rowToConn(row);
} }
// Internal sync reorder — must be called INSIDE a transaction // Internal sync reorder — must be called INSIDE a transaction.
//
// Normalizes priorities to a contiguous 1..N after a DELETE or an explicit
// reorder, so gaps don't accumulate over time.
//
// Deliberately NOT called on insert: a new connection already gets
// MAX(priority)+1, which sorts after every existing row, so the order is
// identical with or without the rewrite. Skipping it there is what makes
// import O(1) per key instead of O(pool) — see createProviderConnection.
function reorderInTx(db, providerId) { function reorderInTx(db, providerId) {
const list = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [providerId]).map(rowToConn); const list = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [providerId]).map(rowToConn);
list.sort((a, b) => { list.sort((a, b) => {
@@ -117,7 +125,10 @@ function reorderInTx(db, providerId) {
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0); return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
}); });
list.forEach((c, i) => { list.forEach((c, i) => {
db.run(`UPDATE providerConnections SET priority = ? WHERE id = ?`, [i + 1, c.id]); const want = i + 1;
if ((c.priority || 0) !== want) {
db.run(`UPDATE providerConnections SET priority = ? WHERE id = ?`, [want, c.id]);
}
}); });
} }
@@ -127,7 +138,21 @@ export async function createProviderConnection(data) {
let result; let result;
db.transaction(() => { db.transaction(() => {
const all = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn); // apikey connections are deduped by name and need only the current max
// priority, so query for those directly instead of loading the whole pool
// (O(pool) per key — the other half of the import cost in #4311). The oauth
// branch below still scans, because its identity rules compare fields
// inside providerSpecificData and have no single-column equivalent.
const isApikey = data.authType === "apikey" && !!data.name;
const all = isApikey
? db.all(
`SELECT * FROM providerConnections WHERE provider = ? AND authType = ? AND name = ?`,
[data.provider, "apikey", data.name]
).map(rowToConn)
: db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn);
const poolSize = isApikey
? db.get(`SELECT COUNT(*) AS n FROM providerConnections WHERE provider = ?`, [data.provider])?.n ?? all.length
: all.length;
let existing = null; let existing = null;
if (data.authType === "oauth" && data.email) { if (data.authType === "oauth" && data.email) {
@@ -169,6 +194,21 @@ export async function createProviderConnection(data) {
// access_token: never dedup — user manages duplicates manually // access_token: never dedup — user manages duplicates manually
if (existing) { if (existing) {
// Name collision on an apikey connection used to silently replace the
// stored apiKey, so a script that reused names ("Key 1", "Key 2", …)
// destroyed existing pool entries with no 409 and no warning. Callers that
// genuinely mean "update this one" pass allowOverwrite; everyone else gets
// a typed error naming the row that would have been replaced. #4311
if (data.allowOverwrite === false) {
const err = new Error(
`A connection named "${existing.name}" already exists for provider "${data.provider}". ` +
`Pass allowOverwrite: true to replace it.`
);
err.code = "PROVIDER_NAME_CONFLICT";
err.existingId = existing.id;
err.existingName = existing.name;
throw err;
}
const normalized = resetHealthStateOnActivation(existing, data); const normalized = resetHealthStateOnActivation(existing, data);
const merged = { ...existing, ...normalized, updatedAt: now }; const merged = { ...existing, ...normalized, updatedAt: now };
upsert(db, merged); upsert(db, merged);
@@ -178,11 +218,15 @@ export async function createProviderConnection(data) {
let connectionName = data.name || null; let connectionName = data.name || null;
if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) { if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) {
connectionName = deriveConnectionName(data, data.email || `Account ${all.length + 1}`); connectionName = deriveConnectionName(data, data.email || `Account ${poolSize + 1}`);
} }
let connectionPriority = data.priority; let connectionPriority = data.priority;
if (!connectionPriority) { if (!connectionPriority) {
connectionPriority = all.reduce((m, c) => Math.max(m, c.priority || 0), 0) + 1; // MAX(priority)+1 in SQL rather than a reduce over the loaded pool: the
// apikey path no longer has the whole pool in memory, and the aggregate
// is served by the index instead of a row scan. #4311
const maxRow = db.get(`SELECT MAX(priority) AS m FROM providerConnections WHERE provider = ?`, [data.provider]);
connectionPriority = (maxRow?.m || 0) + 1;
} }
const conn = { const conn = {
@@ -204,7 +248,11 @@ export async function createProviderConnection(data) {
if (data.email !== undefined) conn.email = data.email; if (data.email !== undefined) conn.email = data.email;
upsert(db, conn); upsert(db, conn);
reorderInTx(db, data.provider); // No reorderInTx here. `conn.priority` is already MAX(priority)+1, so the
// row sorts last and the resulting order is what reorderInTx would have
// produced anyway. The rewrite cost ~2N statements per insert — O(pool) —
// which made a 5k-key import O(n*m): ~25M statements at a 5k pool, and it
// serialized every parallel writer on the same transaction. #4311
result = conn; result = conn;
}); });

View File

@@ -5,8 +5,9 @@ import { getMeta, setMeta } from "../helpers/metaStore.js";
function maskApiKey(key) { function maskApiKey(key) {
if (!key || typeof key !== "string") return null; if (!key || typeof key !== "string") return null;
if (key.length <= 8) return key.charAt(0) + "***"; if (key.length <= 12) return key.charAt(0) + "***";
return key.slice(0, 8) + "***"; // Keep the tail: keys sharing a machine-id prefix (team keys) must not collide.
return key.slice(0, 8) + "***" + key.slice(-4);
} }
const PENDING_TIMEOUT_MS = 60 * 1000; const PENDING_TIMEOUT_MS = 60 * 1000;
@@ -931,7 +932,9 @@ export async function getUsageStats(period = "all") {
const keyInfo = apiKeyMap[r.apiKey]; const keyInfo = apiKeyMap[r.apiKey];
const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "...";
const apiKeyMasked = maskApiKey(r.apiKey); const apiKeyMasked = maskApiKey(r.apiKey);
const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`; // Key by the FULL api key (same as the daily rollup + lastUsed overlay)
// — masking here collided all keys sharing a prefix into one bucket.
const akKey = `${r.apiKey}|${r.model}|${r.provider || "unknown"}`;
if (!stats.byApiKey[akKey]) { if (!stats.byApiKey[akKey]) {
stats.byApiKey[akKey] = { stats.byApiKey[akKey] = {
requests: 0, requests: 0,

View File

@@ -0,0 +1,342 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { access, constants, readFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { randomUUID } from "crypto";
const execFileAsync = promisify(execFile);
export const ZED_DEFAULT_CREDENTIALS_URL = "https://zed.dev";
export const ZED_KEYRING_LABEL = "zed-github-account";
/**
* Resolve the credential URL Zed uses as the keyring/keychain key.
* Defaults to https://zed.dev; honors settings.json credentials_url → server_url.
*/
export async function resolveZedCredentialsUrl() {
const settingsPaths = getZedSettingsPaths();
for (const settingsPath of settingsPaths) {
try {
await access(settingsPath, constants.R_OK);
const raw = await readFile(settingsPath, "utf8");
const settings = JSON.parse(raw);
const url =
(typeof settings?.credentials_url === "string" && settings.credentials_url) ||
(typeof settings?.server_url === "string" && settings.server_url) ||
null;
if (url) return url.replace(/\/+$/, "");
} catch {
// try next path
}
}
return ZED_DEFAULT_CREDENTIALS_URL;
}
function getZedSettingsPaths() {
const home = homedir();
if (process.platform === "darwin") {
return [join(home, "Library/Application Support/Zed/settings.json")];
}
if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
return [join(local, "Zed", "settings.json")];
}
const xdg = process.env.XDG_CONFIG_HOME || join(home, ".config");
return [join(xdg, "zed", "settings.json")];
}
/** Candidate paths for Zed's global kv_store (holds system_id). */
export function getZedGlobalDbPaths() {
const home = homedir();
const channels = ["0-global", "0-stable", "0-preview"];
if (process.platform === "darwin") {
const base = join(home, "Library/Application Support/Zed/db");
return channels.map((c) => join(base, c, "db.sqlite"));
}
if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
const base = join(local, "Zed", "db");
return channels.map((c) => join(base, c, "db.sqlite"));
}
const xdg = process.env.XDG_DATA_HOME || join(home, ".local", "share");
const base = join(xdg, "zed", "db");
return channels.map((c) => join(base, c, "db.sqlite"));
}
/**
* Read system_id from Zed's local kv_store (same id the IDE sends to cloud.zed.dev).
*/
export async function readZedSystemId() {
for (const dbPath of getZedGlobalDbPaths()) {
try {
await access(dbPath, constants.R_OK);
} catch {
continue;
}
const value = await queryKvStore(dbPath, "system_id");
if (value) return String(value).trim();
}
return null;
}
async function queryKvStore(dbPath, key) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
try {
const row = db.prepare("SELECT value FROM kv_store WHERE key = ? LIMIT 1").get(key);
return row?.value || null;
} finally {
db.close();
}
} catch {
// Fall back to sqlite3 CLI when native bindings are unavailable.
}
try {
const { stdout } = await execFileAsync(
"sqlite3",
[dbPath, `SELECT value FROM kv_store WHERE key='${key.replace(/'/g, "''")}' LIMIT 1;`],
{ timeout: 5000, windowsHide: true },
);
const value = String(stdout || "").trim();
return value || null;
} catch {
return null;
}
}
/**
* Read Zed IDE session credentials from the OS secret store.
* @returns {Promise<{ found: boolean, userId?: string, accessToken?: string, systemId?: string, credentialsUrl?: string, error?: string }>}
*/
export async function readZedIdeCredentials() {
const credentialsUrl = await resolveZedCredentialsUrl();
let pair = null;
if (process.platform === "linux") {
pair = await readLinuxCredentials(credentialsUrl);
} else if (process.platform === "darwin") {
pair = await readMacCredentials(credentialsUrl);
} else if (process.platform === "win32") {
pair = await readWindowsCredentials(credentialsUrl);
} else {
return {
found: false,
error: `Zed auto-import is not supported on platform ${process.platform}`,
credentialsUrl,
};
}
if (!pair?.userId || !pair?.accessToken) {
return {
found: false,
error:
pair?.error ||
"Zed IDE session not found in the system keyring. Sign in to Zed, then retry.",
credentialsUrl,
};
}
const systemId = (await readZedSystemId()) || randomUUID();
return {
found: true,
userId: String(pair.userId),
accessToken: String(pair.accessToken),
systemId,
credentialsUrl,
};
}
async function readLinuxCredentials(credentialsUrl) {
// Zed (oo7): attributes url + username, label "zed-github-account".
// Note: secret-tool prints attribute.* lines on stderr and label/secret on stdout.
try {
const { stdout, stderr } = await execFileAsync(
"secret-tool",
["search", "--all", "url", credentialsUrl],
{ timeout: 8000, windowsHide: true },
);
const items = parseSecretToolSearch(`${stderr || ""}\n${stdout || ""}`);
const match =
items.find((item) => item.label === ZED_KEYRING_LABEL && item.username && item.secret) ||
items.find((item) => item.username && item.secret) ||
null;
if (match?.username && match?.secret) {
return { userId: match.username, accessToken: match.secret };
}
} catch (err) {
if (err?.code === "ENOENT") {
return { error: "secret-tool not found (install libsecret / secret-tools)" };
}
// Some secret-tool versions exit non-zero but still print useful output.
const merged = `${err?.stderr || ""}\n${err?.stdout || ""}`;
if (merged.includes("secret =")) {
const items = parseSecretToolSearch(merged);
const match = items.find((item) => item.username && item.secret);
if (match) return { userId: match.username, accessToken: match.secret };
}
}
try {
const lookup = await execFileAsync(
"secret-tool",
["lookup", "url", credentialsUrl],
{ timeout: 8000, windowsHide: true },
);
const accessToken = String(lookup.stdout || "").trim();
if (!accessToken) return { error: "Empty Zed keyring secret" };
const search = await execFileAsync(
"secret-tool",
["search", "--all", "url", credentialsUrl],
{ timeout: 8000, windowsHide: true },
);
const items = parseSecretToolSearch(`${search.stderr || ""}\n${search.stdout || ""}`);
const userId = items.find((i) => i.username)?.username;
if (!userId) return { error: "Zed keyring entry missing username (user id)" };
return { userId, accessToken };
} catch (err) {
if (err?.code === "ENOENT") {
return { error: "secret-tool not found (install libsecret / secret-tools)" };
}
return { error: err?.stderr || err?.message || "Failed to read Linux keyring" };
}
}
function parseSecretToolSearch(stdout) {
// Attributes are often on stderr; callers should concatenate stderr+stdout.
// Format:
// attribute.url = https://zed.dev
// attribute.username = 123
// [/60]
// label = zed-github-account
// secret = {...}
const text = String(stdout || "");
const username = text.match(/attribute\.username\s*=\s*(\S+)/)?.[1] || null;
const label = text.match(/^\s*label\s*=\s*(.+)$/m)?.[1]?.trim() || null;
const secretLine = text.match(/^\s*secret\s*=\s*(.*)$/m)?.[1];
const secret = secretLine != null ? secretLine.trim() : null;
if (!username && !secret && !label) return [];
return [{ label, username, secret }];
}
async function readMacCredentials(credentialsUrl) {
// Zed stores an internet password with kSecAttrServer = full credentials URL.
const servers = unique([credentialsUrl, stripUrlScheme(credentialsUrl), "zed.dev"]);
let lastError = null;
for (const server of servers) {
try {
// -g prints password to stderr as "password: \"...\""
const { stdout, stderr } = await execFileAsync(
"security",
["find-internet-password", "-s", server, "-g"],
{ timeout: 8000, windowsHide: true },
);
const combined = `${stdout || ""}\n${stderr || ""}`;
const password = parseSecurityPassword(combined);
const account =
combined.match(/"acct"<blob>="([^"]*)"/)?.[1] ||
combined.match(/"acct"<blob>=0x[0-9A-Fa-f]+\s+"([^"]*)"/)?.[1] ||
null;
if (password && account) {
return { userId: account, accessToken: password };
}
if (password && !account) {
lastError = "Found Zed keychain password but missing account (user id)";
}
} catch (err) {
lastError = err?.stderr || err?.message || lastError;
}
}
return { error: lastError || "Zed credentials not found in macOS Keychain" };
}
function parseSecurityPassword(text) {
const m = String(text).match(/password:\s*"(.*)"\s*$/m);
if (m) return unescapeSecurityPassword(m[1]);
// Empty password prints as "password: "
if (/password:\s*$/m.test(text)) return "";
return null;
}
function unescapeSecurityPassword(value) {
// security escapes \ and " in the quoted password dump
return value.replace(/\\(.)/g, "$1");
}
async function readWindowsCredentials(credentialsUrl) {
// Zed target name: zed:url=https://zed.dev
const target = `zed:url=${credentialsUrl}`;
const script = `
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class ZedCred {
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public uint Flags; public uint Type; public string TargetName; public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist;
public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern void CredFree(IntPtr buffer);
public static string Read(string target) {
IntPtr p;
if (!CredRead(target, 1, 0, out p)) return null;
try {
var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL));
string secret = "";
if (c.CredentialBlob != IntPtr.Zero && c.CredentialBlobSize > 0) {
byte[] bytes = new byte[c.CredentialBlobSize];
Marshal.Copy(c.CredentialBlob, bytes, 0, (int)c.CredentialBlobSize);
secret = Encoding.UTF8.GetString(bytes);
}
return (c.UserName ?? "") + "\\n" + secret;
} finally { CredFree(p); }
}
}
"@
$r = [ZedCred]::Read(${JSON.stringify(target)})
if ($null -eq $r) { exit 2 }
Write-Output $r
`.trim();
try {
const { stdout } = await execFileAsync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", script],
{ timeout: 15000, windowsHide: true, maxBuffer: 2 * 1024 * 1024 },
);
const text = String(stdout || "").replace(/^\uFEFF/, "");
const nl = text.indexOf("\n");
if (nl < 0) return { error: "Malformed Windows credential payload" };
const userId = text.slice(0, nl).trim();
const accessToken = text.slice(nl + 1).replace(/\r?\n$/, "");
if (!userId || !accessToken) {
return { error: "Windows Credential Manager entry missing username or secret" };
}
return { userId, accessToken };
} catch (err) {
if (err?.code === 2 || err?.status === 2) {
return { error: "Zed credentials not found in Windows Credential Manager" };
}
return { error: err?.stderr || err?.message || "Failed to read Windows credentials" };
}
}
function stripUrlScheme(url) {
return String(url || "").replace(/^https?:\/\//i, "").replace(/\/+$/, "");
}
function unique(arr) {
return [...new Set(arr.filter(Boolean))];
}

View File

@@ -2,6 +2,7 @@
export const HEALTH_CHECK = { export const HEALTH_CHECK = {
intervalMs: 2000, intervalMs: 2000,
timeoutMs: 180000, timeoutMs: 180000,
enableTimeoutMs: 20000, // Enable flow waits short; watchdog re-verifies afterwards
fetchTimeoutMs: 8000, fetchTimeoutMs: 8000,
dnsTimeoutMs: 3000, dnsTimeoutMs: 3000,
}; };

View File

@@ -18,12 +18,12 @@ export async function probeUrlAlive(url) {
} }
} }
export async function waitForHealth(url, cancelToken = { cancelled: false }) { export async function waitForHealth(url, cancelToken = { cancelled: false }, { timeoutMs = HEALTH_CHECK.timeoutMs } = {}) {
const start = Date.now(); const start = Date.now();
while (Date.now() - start < HEALTH_CHECK.timeoutMs) { while (Date.now() - start < timeoutMs) {
if (cancelToken.cancelled) throw new Error("cancelled"); if (cancelToken.cancelled) throw new Error("cancelled");
if (await probeUrlAlive(url)) return true; if (await probeUrlAlive(url)) return true;
await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs)); await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs));
} }
throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`); throw new Error(`Health check timeout after ${timeoutMs}ms`);
} }

View File

@@ -1,6 +1,7 @@
import { loadState, generateShortId } from "../shared/state.js"; import { loadState, generateShortId } from "../shared/state.js";
import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js";
import { waitForHealth } from "./healthCheck.js"; import { waitForHealth } from "./healthCheck.js";
import { HEALTH_CHECK } from "./config.js";
import { getSettings, updateSettings } from "@/lib/localDb"; import { getSettings, updateSettings } from "@/lib/localDb";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
@@ -88,7 +89,7 @@ export async function enableTailscale(localPort = 20128) {
// Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating) // Verify funnel serves /api/health — timeout is non-fatal (DNS may still be propagating)
let reachableNow = false; let reachableNow = false;
try { try {
await waitForHealth(result.tunnelUrl, token); await waitForHealth(result.tunnelUrl, token, { timeoutMs: HEALTH_CHECK.enableTimeoutMs });
reachableNow = true; reachableNow = true;
} catch (he) { } catch (he) {
if (!he.message.startsWith("Health check timeout")) throw he; if (!he.message.startsWith("Health check timeout")) throw he;

View File

@@ -3,11 +3,8 @@
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { marked } from "marked";
import { GITHUB_CONFIG } from "@/shared/constants/config"; import { GITHUB_CONFIG } from "@/shared/constants/config";
marked.setOptions({ gfm: true, breaks: true });
export default function ChangelogModal({ isOpen, onClose }) { export default function ChangelogModal({ isOpen, onClose }) {
const [html, setHtml] = useState(""); const [html, setHtml] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -18,12 +15,17 @@ export default function ChangelogModal({ isOpen, onClose }) {
if (!isOpen || html) return; if (!isOpen || html) return;
setLoading(true); setLoading(true);
setError(""); setError("");
fetch(GITHUB_CONFIG.changelogUrl) Promise.all([
.then((res) => { fetch(GITHUB_CONFIG.changelogUrl).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text(); return res.text();
}),
import("marked"),
])
.then(([md, { marked }]) => {
marked.setOptions({ gfm: true, breaks: true });
setHtml(marked.parse(md));
}) })
.then((md) => setHtml(marked.parse(md)))
.catch((err) => setError(err.message || "Failed to load")) .catch((err) => setError(err.message || "Failed to load"))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [isOpen, html]); }, [isOpen, html]);

View File

@@ -753,25 +753,27 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return ( return (
<Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg"> <Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */} {/* Proxy OAuth (trae/windsurf/zed): browser flow; paste-token only when configured */}
{PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && ( {PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && (
<> <>
<div className="flex gap-2"> {PASTE_TOKEN_PROVIDERS[provider] && (
<button <div className="flex gap-2">
type="button" <button
onClick={() => { setAuthMode("browser"); setError(null); setStep("waiting"); startOAuthFlow(); }} type="button"
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "browser" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`} onClick={() => { setAuthMode("browser"); setError(null); setStep("waiting"); startOAuthFlow(); }}
> className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "browser" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
🌐 Sign in with browser >
</button> 🌐 Sign in with browser
<button </button>
type="button" <button
onClick={() => { setAuthMode("paste-token"); setError(null); setStep("input"); }} type="button"
className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "paste-token" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`} onClick={() => { setAuthMode("paste-token"); setError(null); setStep("input"); }}
> className={`flex-1 rounded-lg border px-3 py-2 text-sm transition-colors ${authMode === "paste-token" ? "border-primary bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"}`}
🔑 Paste token >
</button> 🔑 Paste token
</div> </button>
</div>
)}
{authMode === "browser" && ( {authMode === "browser" && (
<> <>
@@ -801,7 +803,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
</> </>
)} )}
{authMode === "paste-token" && ( {authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider] && (
<div className="space-y-3"> <div className="space-y-3">
{ideStatus && !ideStatus.installed && ( {ideStatus && !ideStatus.installed && (
<div className={`px-3 py-2 rounded-lg text-sm ${PASTE_TOKEN_PROVIDERS[provider].ideOptional ? "bg-blue-500/10 text-blue-700 dark:text-blue-300" : "bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"}`}> <div className={`px-3 py-2 rounded-lg text-sm ${PASTE_TOKEN_PROVIDERS[provider].ideOptional ? "bg-blue-500/10 text-blue-700 dark:text-blue-300" : "bg-yellow-500/10 text-yellow-700 dark:text-yellow-300"}`}>

View File

@@ -8,6 +8,7 @@ import { cn } from "@/shared/utils/cn";
import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config"; import { APP_CONFIG, UPDATER_CONFIG } from "@/shared/constants/config";
import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers"; import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import useSettingsStore from "@/store/settingsStore";
import Button from "./Button"; import Button from "./Button";
import { ConfirmModal } from "./Modal"; import { ConfirmModal } from "./Modal";
import NineRemotePromoModal from "./NineRemotePromoModal"; import NineRemotePromoModal from "./NineRemotePromoModal";
@@ -54,18 +55,20 @@ export default function Sidebar({ onClose }) {
const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest; const INSTALL_CMD = UPDATER_CONFIG.installCmdLatest;
useEffect(() => { useEffect(() => {
fetch("/api/settings") useSettingsStore.getState().fetchSettings().then((data) => {
.then(res => res.json()) if (data?.enableTranslator) setEnableTranslator(true);
.then(data => { if (data.enableTranslator) setEnableTranslator(true); }) });
.catch(() => {});
}, []); }, []);
// Lazy check for new npm version on mount // Lazy check for new npm version in background after initial render
useEffect(() => { useEffect(() => {
fetch("/api/version") const timer = setTimeout(() => {
.then(res => res.json()) fetch("/api/version")
.then(data => { if (data.hasUpdate) setUpdateInfo(data); }) .then(res => res.json())
.catch(() => {}); .then(data => { if (data.hasUpdate) setUpdateInfo(data); })
.catch(() => {});
}, 2500);
return () => clearTimeout(timer);
}, []); }, []);
const isActive = (href) => { const isActive = (href) => {

View File

@@ -26,14 +26,23 @@ import UsageTable, {
fmtTime, fmtTime,
} from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; } from "@/app/(dashboard)/dashboard/usage/components/UsageTable";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import ProviderBarChart from "@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"; // Lazy-load: keeps @xyflow/react and recharts out of the initial bundle
import TopModelsChart from "@/app/(dashboard)/dashboard/usage/components/TopModelsChart";
// Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders
const ProviderTopology = dynamic( const ProviderTopology = dynamic(
() => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), () => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"),
{ ssr: false }, { ssr: false },
); );
import UsageChart from "@/app/(dashboard)/dashboard/usage/components/UsageChart"; const UsageChart = dynamic(
() => import("@/app/(dashboard)/dashboard/usage/components/UsageChart"),
{ ssr: false },
);
const ProviderBarChart = dynamic(
() => import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"),
{ ssr: false },
);
const TopModelsChart = dynamic(
() => import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart"),
{ ssr: false },
);
function timeAgo(timestamp) { function timeAgo(timestamp) {
const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000); const diff = Math.floor((Date.now() - new Date(timestamp)) / 1000);

View File

@@ -0,0 +1,415 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
/**
* Zed Connect modal (Codex-style):
* 1. Auto-import from Zed IDE keyring when a session is detected
* 2. Browser OAuth (local proxy) with waiting spinner
* 3. Manual paste of the callback URL
*/
export default function ZedAuthModal({ isOpen, providerInfo, onSuccess, onClose }) {
const [phase, setPhase] = useState("booting"); // booting | ide-found | browser | importing | success | error
const [ideSession, setIdeSession] = useState(null);
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const popupRef = useRef(null);
const flowRef = useRef({ proxyStarted: false, stopSent: false });
const openedRef = useRef(false);
const pollAbortRef = useRef(false);
const isOpenRef = useRef(isOpen);
const onSuccessRef = useRef(onSuccess);
const onCloseRef = useRef(onClose);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
isOpenRef.current = isOpen;
onSuccessRef.current = onSuccess;
onCloseRef.current = onClose;
});
const stopOwnedProxy = useCallback(() => {
const flow = flowRef.current;
if (flow.proxyStarted && !flow.stopSent) {
flow.stopSent = true;
fetch("/api/oauth/zed/stop-proxy").catch(() => {});
}
}, []);
const finishSuccess = useCallback(() => {
setPhase("success");
onSuccessRef.current?.();
setTimeout(() => onCloseRef.current?.(), 600);
}, []);
const importIdeSession = useCallback(async (session) => {
if (!session?.accessToken || !session?.userId) return;
setBusy(true);
setError(null);
setPhase("importing");
try {
const res = await fetch("/api/oauth/zed/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accessToken: session.accessToken,
userId: session.userId,
...(session.systemId ? { systemId: session.systemId } : {}),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Import failed");
stopOwnedProxy();
finishSuccess();
} catch (err) {
setError(err.message);
setPhase("ide-found");
} finally {
setBusy(false);
}
}, [finishSuccess, stopOwnedProxy]);
const startBrowserFlow = useCallback(async () => {
setError(null);
setAuthData(null);
setCallbackUrl("");
flowRef.current = { proxyStarted: false, stopSent: false };
pollAbortRef.current = false;
try {
const startRes = await fetch("/api/oauth/zed/start-proxy");
const startData = await startRes.json();
if (!startRes.ok || !startData.success || !startData.callbackUrl) {
throw new Error(startData.reason || startData.error || "Failed to start Zed callback server");
}
flowRef.current.proxyStarted = true;
flowRef.current.stopSent = false;
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
const authorizeUrl = new URL("/api/oauth/zed/authorize", window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl);
const authRes = await fetch(authorizeUrl);
const nextAuth = await authRes.json();
if (!authRes.ok) {
stopOwnedProxy();
throw new Error(nextAuth.error || "Failed to start Zed authorization");
}
if (!isOpenRef.current) {
stopOwnedProxy();
return;
}
const regBody = { state: nextAuth.state };
if (nextAuth.codeVerifier) regBody.codeVerifier = nextAuth.codeVerifier;
if (nextAuth.systemId) regBody.systemId = nextAuth.systemId;
const regRes = await fetch("/api/oauth/zed/register-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(regBody),
});
let regData = null;
try {
regData = await regRes.json();
} catch {
regData = null;
}
if (!regRes.ok || regData?.success === false) {
stopOwnedProxy();
throw new Error(regData?.error || "Failed to register Zed login session");
}
if (!isOpenRef.current) return;
setAuthData(nextAuth);
setPhase((prev) => (prev === "ide-found" || prev === "importing" ? prev : "browser"));
popupRef.current = window.open(nextAuth.authUrl, "oauth_popup_zed", "width=600,height=700");
} catch (err) {
if (!isOpenRef.current) return;
setError(err.message);
setPhase((prev) => (prev === "ide-found" ? prev : "browser"));
}
}, [stopOwnedProxy]);
// Open: detect IDE session (auto-import if found), always start browser flow as fallback.
useEffect(() => {
if (!isOpen) return;
if (openedRef.current) return;
openedRef.current = true;
setPhase("booting");
setIdeSession(null);
setAuthData(null);
setCallbackUrl("");
setError(null);
setBusy(false);
pollAbortRef.current = false;
flowRef.current = { proxyStarted: false, stopSent: false };
let cancelled = false;
(async () => {
// Browser flow runs in parallel so paste-callback is always available.
const browserPromise = startBrowserFlow();
try {
const res = await fetch("/api/oauth/zed/auto-import", {
signal: AbortSignal.timeout(12000),
});
const data = await res.json();
if (cancelled || !isOpenRef.current) return;
if (data.found && data.accessToken && data.userId) {
const session = {
accessToken: data.accessToken,
userId: String(data.userId),
systemId: data.systemId || "",
};
setIdeSession(session);
// Auto-import when Zed IDE session is detected.
await importIdeSession(session);
return;
}
} catch {
// Fall through to browser UI
}
if (cancelled || !isOpenRef.current) return;
await browserPromise;
if (!cancelled && isOpenRef.current) setPhase("browser");
})();
return () => {
cancelled = true;
};
}, [isOpen, startBrowserFlow, importIdeSession]);
// Cleanup on close
useEffect(() => {
if (isOpen) return;
openedRef.current = false;
pollAbortRef.current = true;
stopOwnedProxy();
flowRef.current = { proxyStarted: false, stopSent: false };
if (popupRef.current && !popupRef.current.closed) {
try {
popupRef.current.close();
} catch {
// ignore
}
}
}, [isOpen, stopOwnedProxy]);
// Poll proxy until browser OAuth completes
useEffect(() => {
if (!authData?.state) return;
if (phase === "importing" || phase === "success") return;
let cancelled = false;
let attempts = 0;
const MAX_ATTEMPTS = 200;
const tick = async () => {
if (cancelled || pollAbortRef.current || !isOpenRef.current) return;
attempts += 1;
try {
const res = await fetch(
`/api/oauth/zed/poll-status?state=${encodeURIComponent(authData.state)}`,
);
const data = await res.json();
if (cancelled || pollAbortRef.current) return;
if (data.status === "done") {
pollAbortRef.current = true;
stopOwnedProxy();
finishSuccess();
return;
}
if (data.status === "error") {
pollAbortRef.current = true;
setError(data.error || "Authentication failed");
setPhase("error");
return;
}
} catch {
// keep polling
}
if (attempts >= MAX_ATTEMPTS) {
setError("Authentication timeout");
setPhase("error");
return;
}
setTimeout(tick, 1500);
};
setTimeout(tick, 1500);
return () => {
cancelled = true;
};
}, [authData, phase, finishSuccess, stopOwnedProxy]);
const handleManualCallback = async () => {
const input = callbackUrl.trim();
if (!input) return;
setBusy(true);
setError(null);
try {
const res = await fetch("/api/oauth/zed/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: input,
state: authData?.state,
...(authData?.redirectUri ? { redirectUri: authData.redirectUri } : {}),
...(authData?.codeVerifier ? { codeVerifier: authData.codeVerifier } : {}),
...(authData?.systemId ? { systemId: authData.systemId } : {}),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Exchange failed");
pollAbortRef.current = true;
stopOwnedProxy();
finishSuccess();
} catch (err) {
setError(err.message);
setPhase("error");
} finally {
setBusy(false);
}
};
const handleClose = () => {
pollAbortRef.current = true;
stopOwnedProxy();
onClose();
};
const title = `Connect ${providerInfo?.name || "Zed"}`;
const showBrowserUi = phase === "browser" || phase === "error" || phase === "ide-found";
return (
<Modal isOpen={isOpen} title={title} onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{(phase === "booting" || phase === "importing") && (
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-base text-primary animate-spin">
progress_activity
</span>
<span className="text-sm">
{phase === "importing"
? "Importing session from Zed IDE…"
: "Detecting Zed IDE session…"}
</span>
</div>
)}
{phase === "ide-found" && ideSession && (
<div className="space-y-3">
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-green-600 dark:text-green-400">
check_circle
</span>
<p className="text-sm text-green-800 dark:text-green-200">
Zed IDE session detected (user {ideSession.userId}). Import failed — retry or use browser sign-in below.
</p>
</div>
</div>
<Button onClick={() => importIdeSession(ideSession)} fullWidth disabled={busy}>
{busy ? "Importing…" : "Import from Zed IDE"}
</Button>
</div>
)}
{phase === "success" && (
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800 text-sm text-green-800 dark:text-green-200">
Connected successfully.
</div>
)}
{showBrowserUi && (
<>
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-base text-primary animate-spin">
progress_activity
</span>
<span className="text-sm">Waiting for popup authorization…</span>
</div>
<div className="flex items-center gap-3 my-1">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text-muted uppercase tracking-wider">
Or paste callback URL manually
</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
<div className="flex gap-2">
<Input
value={authData?.authUrl || ""}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authData?.authUrl, "auth_url")}
disabled={!authData?.authUrl}
>
Copy
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
<p className="text-xs text-text-muted mb-2">
After authorization, copy the full URL from your browser (or the local callback page).
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="http://127.0.0.1:.../?user_id=...&access_token=..."
className="font-mono text-xs"
/>
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleManualCallback}
fullWidth
disabled={busy || !callbackUrl.trim() || !authData}
>
{busy ? "Connecting…" : "Connect"}
</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
ZedAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
providerInfo: PropTypes.object,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -18,7 +18,6 @@ export { default as ModelSelectModal, ModelSelectSidePanel } from "./ModelSelect
export { default as ManualConfigModal } from "./ManualConfigModal"; export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as ComboFormModal } from "./ComboFormModal"; export { default as ComboFormModal } from "./ComboFormModal";
export { default as McpMarketplaceModal } from "./McpMarketplaceModal"; export { default as McpMarketplaceModal } from "./McpMarketplaceModal";
export { default as UsageStats } from "./UsageStats";
export { default as LanguageSwitcher } from "./LanguageSwitcher"; export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as NineRemoteButton } from "./NineRemoteButton"; export { default as NineRemoteButton } from "./NineRemoteButton";
export { default as HeaderMenu } from "./HeaderMenu"; export { default as HeaderMenu } from "./HeaderMenu";
@@ -28,6 +27,7 @@ export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper"; export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal"; export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal"; export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as ZedAuthModal } from "./ZedAuthModal";
export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal"; export { default as XiaomiMimoAuthModal } from "./XiaomiMimoAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal"; export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal"; export { default as GitLabAuthModal } from "./GitLabAuthModal";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useNotificationStore } from "@/store/notificationStore"; import { useNotificationStore } from "@/store/notificationStore";
import Sidebar from "../Sidebar"; import Sidebar from "../Sidebar";
@@ -37,6 +37,24 @@ export default function DashboardLayout({ children }) {
const notifications = useNotificationStore((state) => state.notifications); const notifications = useNotificationStore((state) => state.notifications);
const removeNotification = useNotificationStore((state) => state.removeNotification); const removeNotification = useNotificationStore((state) => state.removeNotification);
// Preload heavy usage charts in background when browser is idle
useEffect(() => {
const preload = () => {
import("@/shared/components/UsageStats").catch(() => {});
import("@/app/(dashboard)/dashboard/usage/components/UsageChart").catch(() => {});
import("@/app/(dashboard)/dashboard/usage/components/ProviderBarChart").catch(() => {});
import("@/app/(dashboard)/dashboard/usage/components/TopModelsChart").catch(() => {});
};
if (typeof window !== "undefined") {
if ("requestIdleCallback" in window) {
const id = window.requestIdleCallback(preload, { timeout: 4000 });
return () => window.cancelIdleCallback(id);
}
const timer = setTimeout(preload, 2500);
return () => clearTimeout(timer);
}
}, []);
return ( return (
<div className="flex h-screen w-full overflow-hidden bg-bg"> <div className="flex h-screen w-full overflow-hidden bg-bg">
<div className="fixed top-4 right-4 z-[80] flex w-[min(92vw,380px)] flex-col gap-2"> <div className="fixed top-4 right-4 z-[80] flex w-[min(92vw,380px)] flex-col gap-2">

View File

@@ -165,6 +165,22 @@ export const CLI_TOOLS = {
color: "#8B5CF6", color: "#8B5CF6",
description: "Nous Research self-improving AI agent", description: "Nous Research self-improving AI agent",
configType: "custom", configType: "custom",
// Model slots Hermes supports besides the default ("model:" block).
// "default" is not listed — the card renders it as the main model picker.
roles: [
{ id: "delegation", label: "Delegation (subagents)" },
{ id: "vision", label: "Vision" },
{ id: "web_extract", label: "Web Extract" },
{ id: "compression", label: "Compression" },
{ id: "title_generation", label: "Title Generation" },
{ id: "approval", label: "Approval" },
{ id: "skills_hub", label: "Skills Hub" },
{ id: "mcp", label: "MCP" },
{ id: "memory_query_rewrite", label: "Memory Query Rewrite" },
{ id: "background_review", label: "Background Review" },
{ id: "curator", label: "Curator" },
{ id: "monitor", label: "Monitor" },
],
}, },
droid: { droid: {
id: "droid", id: "droid",

View File

@@ -45,3 +45,23 @@ export const CAPACITY_META = {
// search: temporarily hidden (feature not wired yet) // search: temporarily hidden (feature not wired yet)
reasoning: { icon: "neurology", label: "Reasoning", desc: "Supports reasoning / thinking", color: "text-amber-500" }, reasoning: { icon: "neurology", label: "Reasoning", desc: "Supports reasoning / thinking", color: "text-amber-500" },
}; };
// Realtime STT transport markers accepted on custom models — single source of
// truth across layers: the API whitelist (src/app/api/models/custom/route.js
// sanitizeTransport) and the dashboard transport select
// (providers/[id]/AddCustomModelModal) both import this map, so one new row
// here makes a realtime engine dispatch case (open-sse/handlers/sttCore.js)
// selectable and validated end-to-end. Keys must mirror a sttCore case.
export const STT_TRANSPORT_META = {
"gemini-live": {
label: "Gemini Live (realtime WebSocket)",
desc: "Streams audio over bidiGenerateContent and returns incremental transcription segments",
},
};
export const STT_TRANSPORTS = Object.freeze(Object.keys(STT_TRANSPORT_META));
export function isSttTransport(transport) {
if (typeof transport !== "string") return false;
return Object.prototype.hasOwnProperty.call(STT_TRANSPORT_META, transport.trim());
}

View File

@@ -18,6 +18,7 @@ import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js"; import { getTransform as getPxpipeTransform } from "@/lib/pxpipe/loader.js";
import { appendPxpipeEvent } from "@/lib/pxpipe/events.js"; import { appendPxpipeEvent } from "@/lib/pxpipe/events.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { upstreamResponseHeaders } from "open-sse/utils/upstreamHeaders.js";
import { handleComboChat, handleFusionChat, detectRequiredCapabilities } from "open-sse/services/combo.js"; import { handleComboChat, handleFusionChat, detectRequiredCapabilities } from "open-sse/services/combo.js";
import { augmentModelsWithCapacityAdapter, withCapacityAdapterStripping, getActiveAdapterStrategy } from "open-sse/services/capacityAdapter.js"; import { augmentModelsWithCapacityAdapter, withCapacityAdapterStripping, getActiveAdapterStrategy } from "open-sse/services/capacityAdapter.js";
import { handleBypassRequest } from "open-sse/utils/bypassHandler.js"; import { handleBypassRequest } from "open-sse/utils/bypassHandler.js";
@@ -264,6 +265,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const excludeConnectionIds = new Set(); const excludeConnectionIds = new Set();
let lastError = null; let lastError = null;
let lastStatus = null; let lastStatus = null;
let lastHeaders = null;
while (true) { while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId }); const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
@@ -274,14 +276,14 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const errorMsg = lastError || credentials.lastError || "Unavailable"; const errorMsg = lastError || credentials.lastError || "Unavailable";
const status = HTTP_STATUS.SERVICE_UNAVAILABLE; const status = HTTP_STATUS.SERVICE_UNAVAILABLE;
log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`);
return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman, lastHeaders);
} }
if (excludeConnectionIds.size === 0) { if (excludeConnectionIds.size === 0) {
log.warn("AUTH", `No active credentials for provider: ${provider}`); log.warn("AUTH", `No active credentials for provider: ${provider}`);
return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`); return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`);
} }
log.warn("CHAT", "No more accounts available", { provider }); log.warn("CHAT", "No more accounts available", { provider });
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable", lastHeaders);
} }
// Account selection shown in the unified "▶" line (acc:...) // Account selection shown in the unified "▶" line (acc:...)
@@ -387,6 +389,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
excludeConnectionIds.add(credentials.connectionId); excludeConnectionIds.add(credentials.connectionId);
lastError = result.error; lastError = result.error;
lastStatus = result.status; lastStatus = result.status;
lastHeaders = upstreamResponseHeaders(result.response?.headers);
continue; continue;
} }

View File

@@ -2,7 +2,7 @@ import {
extractApiKey, isValidApiKey, extractApiKey, isValidApiKey,
getProviderCredentials, markAccountUnavailable, getProviderCredentials, markAccountUnavailable,
} from "../services/auth.js"; } from "../services/auth.js";
import { getSettings } from "@/lib/localDb"; import { getSettings, getCustomModels } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js"; import { getModelInfo } from "../services/model.js";
import { handleSttCore } from "open-sse/handlers/sttCore.js"; import { handleSttCore } from "open-sse/handlers/sttCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
@@ -17,6 +17,23 @@ const CREDENTIALED_PROVIDERS = new Set(
.map(([id]) => id) .map(([id]) => id)
); );
// Custom-model transport marker: models registered through
// /api/models/custom may pin a specialized STT transport (e.g.
// "gemini-live"). The engine dispatches on the marker itself, so the app
// layer only resolves it — same getModelInfo-style provider+model pairing,
// restricted to type "stt" records.
async function resolveCustomModelTransport(provider, model) {
try {
const customModels = await getCustomModels();
const hit = customModels.find((c) => c && c.type === "stt"
&& c.providerAlias === provider && c.id === model
&& typeof c.transport === "string" && c.transport.trim());
return hit ? hit.transport.trim() : null;
} catch {
return null; // DB unreadable → built-in registry marker still applies
}
}
export async function handleStt(request) { export async function handleStt(request) {
let formData; let formData;
try { try {
@@ -45,9 +62,11 @@ export async function handleStt(request) {
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); log.info("ROUTING", `Provider: ${provider}, Model: ${model}`);
const modelTransport = await resolveCustomModelTransport(provider, model);
// noAuth providers // noAuth providers
if (!CREDENTIALED_PROVIDERS.has(provider)) { if (!CREDENTIALED_PROVIDERS.has(provider)) {
const result = await handleSttCore({ provider, model, formData, sttConfig: AI_PROVIDERS[provider]?.sttConfig }); const result = await handleSttCore({ provider, model, formData, sttConfig: AI_PROVIDERS[provider]?.sttConfig, transport: modelTransport });
if (result.success) return result.response; if (result.success) return result.response;
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "STT failed"); return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "STT failed");
} }
@@ -72,7 +91,7 @@ export async function handleStt(request) {
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig }); const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig, transport: modelTransport });
if (result.success) return result.response; if (result.success) return result.response;

View File

@@ -3,6 +3,8 @@
import { create } from "zustand"; import { create } from "zustand";
import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config"; import { CLIENT_STORE_TTL_MS } from "@/shared/constants/config";
let inFlightSettingsPromise = null;
const useSettingsStore = create((set, get) => ({ const useSettingsStore = create((set, get) => ({
settings: null, settings: null,
loading: false, loading: false,
@@ -11,23 +13,30 @@ const useSettingsStore = create((set, get) => ({
invalidate: () => set({ lastFetched: 0 }), invalidate: () => set({ lastFetched: 0 }),
// Skips network when cache is fresh; pass {force:true} to override // Skips network when cache is fresh; coalesce concurrent in-flight requests
fetchSettings: async ({ force = false } = {}) => { fetchSettings: async ({ force = false } = {}) => {
const { lastFetched, settings } = get(); const { lastFetched, settings } = get();
if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings; if (!force && settings && Date.now() - lastFetched < CLIENT_STORE_TTL_MS) return settings;
if (inFlightSettingsPromise) return inFlightSettingsPromise;
set({ loading: true, error: null }); set({ loading: true, error: null });
try { inFlightSettingsPromise = (async () => {
const res = await fetch("/api/settings"); try {
const data = await res.json(); const res = await fetch("/api/settings");
if (res.ok) { const data = await res.json();
set({ settings: data, loading: false, lastFetched: Date.now() }); if (res.ok) {
return data; set({ settings: data, loading: false, lastFetched: Date.now() });
return data;
}
set({ error: data.error, loading: false });
} catch (e) {
set({ error: "Failed to fetch settings", loading: false });
} finally {
inFlightSettingsPromise = null;
} }
set({ error: data.error, loading: false }); return null;
} catch (e) { })();
set({ error: "Failed to fetch settings", loading: false }); return inFlightSettingsPromise;
}
return null;
}, },
// PATCH server + merge into local cache (no extra fetch needed) // PATCH server + merge into local cache (no extra fetch needed)
@@ -40,7 +49,8 @@ const useSettingsStore = create((set, get) => ({
}); });
if (!res.ok) return null; if (!res.ok) return null;
const updated = await res.json(); const updated = await res.json();
set({ settings: updated, lastFetched: Date.now() }); // Merge, not replace: PATCH response omits GET-only fields (e.g. hasPassword)
set({ settings: { ...get().settings, ...updated }, lastFetched: Date.now() });
return updated; return updated;
} catch { } catch {
return null; return null;

View File

@@ -119,6 +119,7 @@
"morphllm": "morph" "morphllm": "morph"
}, },
"idToAlias": { "idToAlias": {
"agnes": "agnes",
"alicode": "alicode", "alicode": "alicode",
"alicode-intl": "alicode-intl", "alicode-intl": "alicode-intl",
"alims-intl": "alims-intl", "alims-intl": "alims-intl",
@@ -127,7 +128,9 @@
"antigravity": "ag", "antigravity": "ag",
"api-airforce": "af", "api-airforce": "af",
"assemblyai": "assemblyai", "assemblyai": "assemblyai",
"atria": "atria",
"azure": "azure", "azure": "azure",
"bai": "bai",
"baidu": "qianfan", "baidu": "qianfan",
"bazaarlink": "bzl", "bazaarlink": "bzl",
"blackbox": "blackbox", "blackbox": "blackbox",
@@ -145,6 +148,7 @@
"cohere": "cohere", "cohere": "cohere",
"commandcode": "commandcode", "commandcode": "commandcode",
"cursor": "cu", "cursor": "cu",
"dahl": "dahl",
"deepgram": "deepgram", "deepgram": "deepgram",
"deepseek": "deepseek", "deepseek": "deepseek",
"featherless": "featherless", "featherless": "featherless",
@@ -192,6 +196,7 @@
"siliconflow": "siliconflow", "siliconflow": "siliconflow",
"tencent": "hunyuan", "tencent": "hunyuan",
"together": "together", "together": "together",
"tokenharbor": "tokenharbor",
"tokenrouter": "tokenrouter", "tokenrouter": "tokenrouter",
"venice": "venice", "venice": "venice",
"vercel-ai-gateway": "vercel-ai-gateway", "vercel-ai-gateway": "vercel-ai-gateway",
@@ -212,6 +217,7 @@
"alitp-intl", "alitp-intl",
"anthropic", "anthropic",
"assemblyai", "assemblyai",
"atria",
"black-forest-labs", "black-forest-labs",
"blackbox", "blackbox",
"bm", "bm",
@@ -229,6 +235,7 @@
"commandcode", "commandcode",
"cu", "cu",
"cx", "cx",
"dahl",
"deepgram", "deepgram",
"deepseek", "deepseek",
"edge-tts", "edge-tts",
@@ -293,6 +300,7 @@
"siliconflow", "siliconflow",
"stability-ai", "stability-ai",
"together", "together",
"tokenharbor",
"tokenrouter", "tokenrouter",
"venice", "venice",
"vertex", "vertex",

View File

@@ -93,7 +93,7 @@
"Anthropic-Version": "2023-06-01", "Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
"Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "User-Agent": "claude-cli/2.1.280 (external, sdk-cli)",
"X-App": "cli", "X-App": "cli",
"X-Stainless-Helper-Method": "stream", "X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0", "X-Stainless-Retry-Count": "0",
@@ -1085,5 +1085,33 @@
"preserveCacheControl": true "preserveCacheControl": true
}, },
"format": "openai" "format": "openai"
},
"dahl": {
"baseUrl": "https://inference.dahl.global/v1/chat/completions",
"validateUrl": "https://inference.dahl.global/v1/models",
"format": "openai"
},
"atria": {
"baseUrl": "https://api.atria-asi.ai/v1/chat/completions",
"validateUrl": "https://api.atria-asi.ai/v1/models",
"format": "openai"
},
"agnes": {
"baseUrl": "https://apihub.agnes-ai.com/v1/chat/completions",
"validateUrl": "https://apihub.agnes-ai.com/v1/models",
"format": "openai"
},
"bai": {
"baseUrl": "https://api.b.ai/v1/chat/completions",
"validateUrl": "https://api.b.ai/v1/models",
"format": "openai"
},
"tokenharbor": {
"baseUrl": "https://tokenharbor.ai/v1/chat/completions",
"validateUrl": "https://tokenharbor.ai/v1/models",
"retry": {
"429": 2
},
"format": "openai"
} }
} }

View File

@@ -119,6 +119,7 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive outpu
}, },
], ],
"thinking": { "thinking": {
"display": "summarized",
"type": "adaptive", "type": "adaptive",
}, },
} }

View File

@@ -17,21 +17,6 @@ exports[`GOLDEN response stream: Claude → OpenAI > text + thinking + tool_use
"model": "claude-opus-4-6", "model": "claude-opus-4-6",
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
}, },
{
"choices": [
{
"delta": {
"content": "<think>",
},
"finish_reason": null,
"index": 0,
},
],
"created": 0,
"id": "chatcmpl-msg_1",
"model": "claude-opus-4-6",
"object": "chat.completion.chunk",
},
{ {
"choices": [ "choices": [
{ {
@@ -47,21 +32,6 @@ exports[`GOLDEN response stream: Claude → OpenAI > text + thinking + tool_use
"model": "claude-opus-4-6", "model": "claude-opus-4-6",
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
}, },
{
"choices": [
{
"delta": {
"content": "</think>",
},
"finish_reason": null,
"index": 0,
},
],
"created": 0,
"id": "chatcmpl-msg_1",
"model": "claude-opus-4-6",
"object": "chat.completion.chunk",
},
{ {
"choices": [ "choices": [
{ {

View File

@@ -36,10 +36,10 @@ describe("Claude → Claude streaming passthrough (OAuth tool cloak)", () => {
expect(outText).toBe(textChunk); expect(outText).toBe(textChunk);
}); });
it("is a no-op when no cloak map is present", () => { it("falls back to suffix-stripping when no cloak map is present", () => {
const chunk = toolUseStart(CLOAKED); const chunk = toolUseStart(CLOAKED);
const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, chunk, {}); const [out] = translateResponse(FORMATS.CLAUDE, FORMATS.CLAUDE, chunk, {});
expect(out).toBe(chunk); expect(out.content_block.name).toBe("run_code");
}); });
it("tolerates the null flush chunk", () => { it("tolerates the null flush chunk", () => {

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