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
176
.github/workflows/tray-binaries.yml
vendored
Normal 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"
|
||||||
|
|
||||||
30
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -1,2 +1,3 @@
|
|||||||
app/*
|
app/*
|
||||||
node_modules/*
|
node_modules/*
|
||||||
|
.tray-build/
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
108
cli/scripts/buildTrayArm64.js
Normal 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`);
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
266
open-sse/handlers/geminiLiveStt.js
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
29
open-sse/providers/registry/agnes.js
Normal 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,
|
||||||
|
};
|
||||||
32
open-sse/providers/registry/atria.js
Normal 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,
|
||||||
|
};
|
||||||
30
open-sse/providers/registry/bai.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
35
open-sse/providers/registry/dahl.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
49
open-sse/providers/registry/tokenharbor.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
12
open-sse/utils/upstreamHeaders.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/providers/atria.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/providers/bai.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
public/providers/dahl.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
BIN
public/providers/tokenharbor.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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}` : ""}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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] : "";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
123
src/app/api/cli-tools/codex-profiles/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
39
src/app/api/oauth/zed/auto-import/route.js
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/app/api/oauth/zed/import/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : [])
|
||||||
|
|||||||
40
src/app/api/usage/[connectionId]/claude-reset/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
342
src/lib/oauth/utils/zedCredentials.js
Normal 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))];
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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"}`}>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
415
src/shared/components/ZedAuthModal.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +119,7 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive outpu
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"thinking": {
|
"thinking": {
|
||||||
|
"display": "summarized",
|
||||||
"type": "adaptive",
|
"type": "adaptive",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||