Merge remote-tracking branch 'origin/master' into gitea/new_feature

# Conflicts:
#	open-sse/executors/qoder.js
#	open-sse/handlers/chatCore.js
#	open-sse/handlers/chatCore/sseToJsonHandler.js
#	open-sse/providers/registry/commandcode.js
#	src/app/(dashboard)/dashboard/combos/page.js
#	src/app/api/v1/models/route.js
#	src/lib/db/repos/usageRepo.js
#	src/shared/components/UsageStats.js
This commit is contained in:
2026-09-25 10:25:56 +07:00
154 changed files with 10246 additions and 1241 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -5,22 +5,164 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Existing vX.Y.Z tag to publish"
required: true
type: string
promote_latest:
description: "Promote this republish to latest"
required: false
default: false
type: boolean
# Keep every release in one FIFO queue. A per-tag group would still allow an
# older release to finish after a newer release and move latest backwards.
concurrency:
group: docker-publish-${{ github.repository }}
cancel-in-progress: false
queue: max
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: decolua/9router
jobs:
build-and-push:
prepare:
name: Validate release
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
commit: ${{ steps.release.outputs.commit }}
publish_dockerhub: ${{ steps.release.outputs.publish_dockerhub }}
promote_latest: ${{ steps.release.outputs.promote_latest }}
ghcr_image: ${{ steps.release.outputs.ghcr_image }}
steps:
- name: Check out release tag
uses: actions/checkout@v4
with:
ref: ${{ inputs.release_tag || github.ref_name }}
fetch-depth: 1
- name: Validate tag and package versions
id: release
env:
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PROMOTE_LATEST_INPUT: ${{ inputs.promote_latest && 'true' || 'false' }}
run: |
node <<'NODE'
const fs = require("fs");
const { execFileSync } = require("child_process");
const tag = process.env.RELEASE_TAG || "";
const match = /^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$/.exec(tag);
if (tag.includes("+")) {
console.error(`Build metadata is not supported in Docker release tags: ${tag}`);
process.exit(1);
}
if (!match) {
console.error(`Expected a Docker-safe semver tag like v0.5.81 or v0.5.81-rc.1, received: ${tag || "<empty>"}`);
process.exit(1);
}
const version = match[1];
if (version.length > 128 || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(version)) {
console.error(`Version is not a valid Docker tag: ${version}`);
process.exit(1);
}
const prerelease = version.includes("-")
? version.slice(version.indexOf("-") + 1).split(".")
: [];
for (const identifier of prerelease) {
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
console.error(`Numeric prerelease identifiers cannot contain leading zeroes: ${identifier}`);
process.exit(1);
}
}
const rootVersion = require("./package.json").version;
const cliVersion = require("./cli/package.json").version;
if (rootVersion !== version) {
console.error(`package.json version ${rootVersion} does not match tag ${tag}`);
process.exit(1);
}
if (cliVersion !== version) {
console.error(`cli/package.json version ${cliVersion} does not match tag ${tag}`);
process.exit(1);
}
const commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const publishDockerHub = process.env.REPOSITORY === "decolua/9router";
const ghcrImage = `ghcr.io/${process.env.REPOSITORY.toLowerCase()}`;
const isPrerelease = version.includes("-");
const promoteLatest = (process.env.EVENT_NAME === "push" && !isPrerelease)
|| process.env.PROMOTE_LATEST_INPUT === "true";
const output = process.env.GITHUB_OUTPUT;
fs.appendFileSync(output, `tag=${tag}\n`);
fs.appendFileSync(output, `version=${version}\n`);
fs.appendFileSync(output, `commit=${commit}\n`);
fs.appendFileSync(output, `publish_dockerhub=${publishDockerHub}\n`);
fs.appendFileSync(output, `promote_latest=${promoteLatest}\n`);
fs.appendFileSync(output, `ghcr_image=${ghcrImage}\n`);
console.log(`Validated ${tag} at ${commit}`);
console.log(`latest promotion: ${promoteLatest ? "enabled" : "disabled"}`);
NODE
build:
name: Build ${{ matrix.platform }}
needs: prepare
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
env:
GHCR_IMAGE: ${{ needs.prepare.outputs.ghcr_image }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
suffix: amd64
runner: ubuntu-24.04
- platform: linux/arm64
suffix: arm64
runner: ubuntu-24.04-arm
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Check out release source at validated commit
uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.commit }}
path: source
fetch-depth: 1
- uses: docker/setup-buildx-action@v3
- name: Check out publishing Dockerfile
uses: actions/checkout@v4
with:
ref: ${{ github.workflow_sha }}
path: workflow
sparse-checkout: |
Dockerfile
fetch-depth: 1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
@@ -29,32 +171,267 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push platform image by digest
id: build
uses: docker/build-push-action@v6
with:
context: source
file: workflow/Dockerfile
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: |
APP_VERSION=${{ needs.prepare.outputs.version }}
ALPINE_MIRROR=${{ vars.ALPINE_MIRROR || 'dl-cdn.alpinelinux.org' }}
NPM_REGISTRY=${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org/' }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ needs.prepare.outputs.commit }}
org.opencontainers.image.version=${{ needs.prepare.outputs.version }}
cache-from: type=gha,scope=9router-${{ matrix.suffix }}
cache-to: type=gha,mode=max,scope=9router-${{ matrix.suffix }}
provenance: false
sbom: false
- name: Smoke-test platform image before publishing digest artifact
env:
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
PLATFORM: ${{ matrix.platform }}
run: |
set -Eeuo pipefail
[[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
container="9router-platform-smoke-${GITHUB_RUN_ID}-${{ matrix.suffix }}"
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT
docker run --detach \
--name "$container" \
--platform "$PLATFORM" \
--publish 20128:20128 \
"${GHCR_IMAGE}@${IMAGE_DIGEST}"
for attempt in {1..45}; do
if curl --fail --silent --show-error http://127.0.0.1:20128/api/health; then
echo "${PLATFORM} health check passed"
exit 0
fi
if (( attempt % 5 == 0 )); then
echo "Waiting for ${PLATFORM} health check (${attempt}/45)" >&2
fi
sleep 2
done
echo "${PLATFORM} health check failed; container logs follow:" >&2
docker logs "$container" || true
exit 1
- name: Save image digest
env:
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
test -n "$IMAGE_DIGEST"
mkdir -p "$RUNNER_TEMP/digests"
printf '%s\n' "$IMAGE_DIGEST" > "$RUNNER_TEMP/digests/${{ matrix.suffix }}.txt"
- name: Upload image digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.suffix }}
path: ${{ runner.temp }}/digests/${{ matrix.suffix }}.txt
if-no-files-found: error
publish:
name: Publish and verify manifest
needs:
- prepare
- build
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GHCR_IMAGE: ${{ needs.prepare.outputs.ghcr_image }}
permissions:
contents: read
packages: write
steps:
- name: Download platform digests
uses: actions/download-artifact@v4
with:
pattern: digests-*
path: ${{ runner.temp }}/digests
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and verify version manifest
env:
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
shopt -s nullglob
digest_files=("$RUNNER_TEMP"/digests/*.txt)
if [[ "${#digest_files[@]}" -ne 2 ]]; then
echo "Expected two platform digests, found ${#digest_files[@]}" >&2
exit 1
fi
sources=()
for digest_file in "${digest_files[@]}"; do
digest="$(tr -d '\n' < "$digest_file")"
if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "Invalid image digest in $digest_file: $digest" >&2
exit 1
fi
sources+=("${GHCR_IMAGE}@${digest}")
done
docker buildx imagetools create \
--tag "${GHCR_IMAGE}:${VERSION}" \
"${sources[@]}"
docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}" | tee "$RUNNER_TEMP/version-manifest.txt"
docker buildx imagetools inspect --raw "${GHCR_IMAGE}:${VERSION}" > "$RUNNER_TEMP/version-manifest.json"
expected=$'linux/amd64\nlinux/arm64'
actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/version-manifest.json")"
if [[ "$actual" != "$expected" ]]; then
echo "Version manifest platforms do not match exactly:" >&2
printf '%s\n' "$actual" >&2
exit 1
fi
- name: Smoke-test resolved version manifest
env:
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -Eeuo pipefail
container="9router-manifest-smoke-${GITHUB_RUN_ID}"
trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT
docker run --detach \
--name "$container" \
--platform linux/amd64 \
--publish 20128:20128 \
"${GHCR_IMAGE}:${VERSION}"
for attempt in {1..30}; do
if curl --fail --silent --show-error http://127.0.0.1:20128/api/health; then
echo "Resolved version manifest health check passed"
exit 0
fi
if (( attempt % 5 == 0 )); then
echo "Waiting for resolved manifest health check (${attempt}/30)" >&2
fi
sleep 2
done
echo "Resolved version manifest health check failed; container logs follow:" >&2
docker logs "$container" || true
exit 1
- name: Log in to Docker Hub
if: needs.prepare.outputs.publish_dockerhub == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_IMAGE }}
${{ env.DOCKERHUB_IMAGE }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Publish version image to Docker Hub
if: needs.prepare.outputs.publish_dockerhub == 'true'
env:
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
docker buildx imagetools create \
--tag "${DOCKERHUB_IMAGE}:${VERSION}" \
"${GHCR_IMAGE}:${VERSION}"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache,mode=max
platforms: linux/amd64,linux/arm64
provenance: false
sbom: false
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}" | tee "$RUNNER_TEMP/dockerhub-version-manifest.txt"
docker buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}:${VERSION}" > "$RUNNER_TEMP/dockerhub-version-manifest.json"
expected=$'linux/amd64\nlinux/arm64'
actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/dockerhub-version-manifest.json")"
if [[ "$actual" != "$expected" ]]; then
echo "Docker Hub version manifest platforms do not match exactly:" >&2
printf '%s\n' "$actual" >&2
exit 1
fi
- name: Record latest promotion policy
env:
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
if [[ "$PROMOTE_LATEST" == "true" ]]; then
echo "### Latest promotion" >> "$GITHUB_STEP_SUMMARY"
echo "- Policy: promote \`latest\` after the verified ${VERSION} manifest." >> "$GITHUB_STEP_SUMMARY"
else
echo "### Latest promotion" >> "$GITHUB_STEP_SUMMARY"
echo "- Policy: leave \`latest\` unchanged; this is a numbered-tag-only manual republish." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Promote verified version to latest
if: needs.prepare.outputs.promote_latest == 'true'
env:
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
PUBLISH_DOCKERHUB: ${{ needs.prepare.outputs.publish_dockerhub }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
docker buildx imagetools create \
--tag "${GHCR_IMAGE}:latest" \
"${GHCR_IMAGE}:${VERSION}"
if [[ "$PUBLISH_DOCKERHUB" == "true" ]]; then
docker buildx imagetools create \
--tag "${DOCKERHUB_IMAGE}:latest" \
"${GHCR_IMAGE}:${VERSION}"
fi
docker buildx imagetools inspect "${GHCR_IMAGE}:latest" | tee "$RUNNER_TEMP/ghcr-latest-manifest.txt"
docker buildx imagetools inspect --raw "${GHCR_IMAGE}:latest" > "$RUNNER_TEMP/ghcr-latest-manifest.json"
expected=$'linux/amd64\nlinux/arm64'
actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/ghcr-latest-manifest.json")"
if [[ "$actual" != "$expected" ]]; then
echo "GHCR latest manifest platforms do not match exactly:" >&2
printf '%s\n' "$actual" >&2
exit 1
fi
if [[ "$PUBLISH_DOCKERHUB" == "true" ]]; then
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:latest" | tee "$RUNNER_TEMP/dockerhub-latest-manifest.txt"
docker buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}:latest" > "$RUNNER_TEMP/dockerhub-latest-manifest.json"
actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/dockerhub-latest-manifest.json")"
if [[ "$actual" != "$expected" ]]; then
echo "Docker Hub latest manifest platforms do not match exactly:" >&2
printf '%s\n' "$actual" >&2
exit 1
fi
fi
{
echo "### Published Docker images"
echo "- GHCR: \`${GHCR_IMAGE}:${VERSION}\`"
echo "- GHCR latest: \`${GHCR_IMAGE}:latest\`"
if [[ "$PUBLISH_DOCKERHUB" == "true" ]]; then
echo "- Docker Hub: \`${DOCKERHUB_IMAGE}:${VERSION}\`"
echo "- Docker Hub latest: \`${DOCKERHUB_IMAGE}:latest\`"
fi
} >> "$GITHUB_STEP_SUMMARY"

View File

@@ -1,3 +1,34 @@
# v0.5.86 (2026-09-23)
## Features
- **Xiaomi MiMo**: server-assisted desktop login for headless/Docker deployments, five account clusters (cn/sgp/ams/ru/in), and v2.6 pro/flash/pro-ultraspeed models with dual-route (account service vs. cloud API)
- **Claude**: add Claude Opus 5.5 support
- **i18n**: translate React text rewrites via characterData mutation observer
## Fixes
- **Proxy Pools**: keep request headers intact through Vercel/Cloudflare/Deno relays (spreading a `Headers` instance yielded `{}`, dropping auth and content-type)
- **Xiaomi MiMo login**: keep the session in the httpOnly cookie only, require dashboard auth on the proxy branch, and stop forwarding authorization headers upstream
# v0.5.85 (2026-09-22)
## Features
- **System One**: add `/v1/systemone` decision endpoint for Jev models (OpenCode Zen and OpenRouter lanes), wire into sidebar and Media Providers page with interactive probe testing
- **CLI Tools**: add dynamic configuration, settings APIs, and official logos for Pi, OMP, Crush, ForgeCode, Smelt, and CodeWhale
- **Analytics & Usage**: add Requests mode, provider/model breakdown charts, All Time period filter, and refined overview cards
- **Combos**: add Cursor/Claude Default presets; support bulk select/delete and bulk strategy changes (Fallback / Round Robin / Fusion)
- **Model Capabilities**: expose model capability metadata on `/v1/models` and aggregate capabilities across combo targets
- **OpenCode Zen & MiMo**: add OpenCode Zen (`opencode-zen`) provider with free-tier fingerprint; switch default vision fallback to MiMo V2.6 Flash Free
- **Qoder CN**: add `qoder-cn` provider for qoder.com.cn with OAuth flow, COSY protocol, and CN gateway routing
## Fixes
- **Translator**: map Claude `refusal` stop_reason to `content_filter` and surface explanation; strip replayed reasoning fields for Groq, Mistral, and Cerebras (#4220)
- **Antigravity**: drop requestType `agent` to avoid false 429 `RESOURCE_EXHAUSTED`; separate weekly and short-window (5-hour) quotas and deduplicate dashboard rows
- **Responses API**: report usage on `response.completed` so clients can auto-compact (#3432)
- **Hugging Face**: migrate to Inference Providers router (`router.huggingface.co`), expand image models catalog, and add STT route
- **Qoder**: prevent signed request replay (`403/103 Duplicate request`), handle code 110 billing blocks, and preserve upstream SSE error status
- **Performance**: bound usage `lastUsed` scan to a 2-day window; map large budget tokens to `max` reasoning tier
- **Docker**: publish verified multi-platform images (linux/amd64 and linux/arm64) with configurable apk build mirrors
# v0.5.81 (2026-09-18)
## Features
@@ -7,6 +38,8 @@
- **i18n**: integrate Persian (fa) translation
## Fixes
- **Cursor**: stop AgentService empty turns (`OUT 0`) and silent hangs — fold system prompts instead of `custom_system_prompt`, send `ModelDetails`, read Composer/Grok `thinking_delta`, ack request-context without echoing MCP tools, and reject IDE execs so the model can continue
- **RTK**: for Cursor, compress source-format `tool_result` / `role:tool` **before** translation — its translator rewrites those shapes, so post-translate compression missed them. Other providers keep the post-translate pass unchanged
- **OpenCode / OpenCode Go**: resolve 403 `FreeTierError` and 429 rate limits with canonical session format, valid User-Agent, and stable upstream session reuse; force stream and declare `forceStream` for free-tier SSE aggregation; cloak decoy tools, normalize Muse Free tool choice, and strip prior reasoning items on Responses models; route Union Alpha via Messages API
- **Kiro**: preserve underscores in tool names (`mcp__server__tool`) and restore client tool names in responses; use neutral placeholder for tool-result-only turns; forward tool-result images
- **Stream**: report aborts after HTTP 200 in-band (per-format error frames) instead of closing silently

View File

@@ -100,6 +100,12 @@ docker rm -f 9router
# re-run the quick start command
```
To pin a specific version instead of following `latest`, use a numbered image tag:
```bash
docker pull decolua/9router:0.5.81
```
---
# 🛠 For Developers
@@ -107,7 +113,7 @@ docker rm -f 9router
## Build image locally (test)
```bash
cd app && docker build -t 9router .
docker build -t 9router .
docker run --rm -p 20128:20128 \
-v "$HOME/.9router:/app/data" \
@@ -115,18 +121,67 @@ docker run --rm -p 20128:20128 \
9router
```
The Dockerfile uses the official Alpine and npm registries by default. Regional mirrors can be supplied when needed:
```bash
docker build \
--build-arg ALPINE_MIRROR=mirrors.aliyun.com \
--build-arg NPM_REGISTRY=https://registry.npmmirror.com/ \
-t 9router .
```
## Publish (automatic via CI)
Push a git tag `v*` → GitHub Actions builds multi-platform (amd64+arm64) and pushes to:
- `ghcr.io/decolua/9router:v{version}` + `:latest`
- `decolua/9router:v{version}` + `:latest`
Push a Docker-safe semver git tag `vX.Y.Z` (or a prerelease such as `vX.Y.Z-rc.1`) → GitHub Actions builds `linux/amd64` and `linux/arm64` on native runners, health-checks each platform image, verifies the resulting manifest and `/api/health`, then publishes:
- `ghcr.io/decolua/9router:X.Y.Z` + `:latest`
- `decolua/9router:X.Y.Z` + `:latest`
The `v` prefix is used only for the git tag; image tags omit it. A stable tag push promotes `latest`, but a prerelease tag such as `vX.Y.Z-rc.1` publishes only its numbered image by default. Prereleases require an explicit manual `promote_latest` opt-in. Promotion happens only after both native platform builds, both platform health checks, manifest inspection, and the resolved-manifest smoke test succeed. A failed or timed-out platform build therefore cannot move `latest`.
The workflow rejects SemVer build metadata such as `v1.2.3+build.7` because the `+` form is not a valid Docker image tag. The git tag and both `package.json` versions must match exactly.
```bash
# Use scripts/release.js (recommended)
node scripts/release.js "Release title" "Notes"
# Or manually
git tag v0.4.x && git push origin v0.4.x
git tag v0.5.81 && git push origin v0.5.81
```
Workflow: `app/.github/workflows/docker-publish.yml`
To republish an existing tag, run the `Build and Push Docker Image` workflow manually and provide the exact tag, for example `v0.5.81`, in the `release_tag` input. Manual runs publish the numbered tag but leave `latest` unchanged by default:
```text
release_tag: v0.5.81
promote_latest: false
```
The `promote_latest` checkbox is an explicit opt-in for changing `latest`. Use it when a deliberate rollback or recovery should make that version the current default:
```text
release_tag: v0.5.75
promote_latest: true
```
Numbered image tags are mutable because a republish can replace their manifest. For a deployment that must be immutable, pin the image digest instead:
```bash
docker pull decolua/9router@sha256:<verified-digest>
```
The release workflow runs `/api/health` on each native `amd64` and `arm64` platform image before it uploads the digest artifact or assembles the multi-platform manifest. It then runs a second health check against the resolved version manifest before any requested `latest` promotion.
During recovery, the selected tag remains the application source while the Dockerfile from the workflow revision is used, so an older tag can be rebuilt with the current publishing fixes.
The workflow is tag-driven. Creating a git tag does not automatically create a GitHub Release, so the Releases page and the published package/image tags can be at different versions unless a maintainer creates a release separately.
The upstream repository needs these repository secrets for Docker Hub publishing:
- `DOCKERHUB_USERNAME`
- `DOCKERHUB_TOKEN`
GHCR publishing uses the workflow's `GITHUB_TOKEN` with package write permission. Forks can publish to their own GHCR namespace, but Docker Hub publication is restricted to the upstream `decolua/9router` repository.
The optional repository variables `ALPINE_MIRROR` and `NPM_REGISTRY` can override the default package mirrors used by the CI Docker build.
Workflow: `.github/workflows/docker-publish.yml`

View File

@@ -1,16 +1,33 @@
# syntax=docker/dockerfile:1.7
ARG NODE_IMAGE=node:22-alpine
ARG ALPINE_MIRROR=dl-cdn.alpinelinux.org
ARG NPM_REGISTRY=https://registry.npmjs.org/
ARG APP_VERSION=unknown
FROM ${NODE_IMAGE} AS base
ARG ALPINE_MIRROR
WORKDIR /app
# CN mirror for apk (used by builder and runner stages)
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
# Use the official Alpine mirror by default. A repository variable/build arg can
# override it for environments that require a regional mirror.
RUN if [ "$ALPINE_MIRROR" != "dl-cdn.alpinelinux.org" ]; then \
sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories; \
fi
FROM base AS builder
ARG NPM_REGISTRY
RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
RUN apk add --no-cache python3 make g++ linux-headers
COPY package.json ./
RUN npm install --registry=https://registry.npmmirror.com
RUN --mount=type=cache,target=/root/.npm \
npm install \
--registry="${NPM_REGISTRY}" \
--fetch-retries=5 \
--fetch-retry-factor=2 \
--fetch-retry-mintimeout=10000 \
--fetch-retry-maxtimeout=120000 \
--fetch-timeout=300000
COPY . ./
ENV NEXT_TELEMETRY_DISABLED=1
@@ -19,9 +36,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM ${NODE_IMAGE} AS runner
ARG ALPINE_MIRROR
ARG APP_VERSION
WORKDIR /app
LABEL org.opencontainers.image.title="9router"
RUN if [ "$ALPINE_MIRROR" != "dl-cdn.alpinelinux.org" ]; then \
sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories; \
fi
LABEL org.opencontainers.image.title="9router" \
org.opencontainers.image.version="${APP_VERSION}"
ENV NODE_ENV=production
ENV PORT=20128
@@ -50,8 +74,9 @@ RUN mkdir -p /app/data && chown -R node:node /app && \
mkdir -p /app/data-home && chown node:node /app/data-home && \
ln -sf /app/data-home /root/.9router 2>/dev/null || true
# Fix permissions at runtime (handles mounted volumes)
RUN apk --no-cache upgrade && apk --no-cache add su-exec && \
# Avoid a full distribution upgrade in the runtime image. It makes builds less
# reproducible and is unrelated to installing the runtime entrypoint helper.
RUN apk add --no-cache su-exec && \
printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \
chmod +x /entrypoint.sh

View File

@@ -110,7 +110,22 @@ PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
Production mode:
```bash
# Create Temporary Memory For Build
sudo fallocate -l 2G /swapfile_temp
sudo chmod 600 /swapfile_temp
sudo mkswap /swapfile_temp
sudo swapon /swapfile_temp
export MAKEFLAGS="-j1"
export DLIB_NO_GUI_SUPPORT=1
export CFLAGS="-mno-avx"
npm run build
# Clear temporary swap
sudo swapoff /swapfile_temp
sudo rm /swapfile_temp
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
```
@@ -215,7 +230,14 @@ Default URLs:
<b>🇻🇳 Tiếng Việt</b><br/>
<sub>Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z<br/>by <a href="https://github.com/tuanminhhole">tuanminhhole</a></sub>
</td>
<td align="center" width="320"></td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=hgnE7MKi3Y4">
<img src="https://img.youtube.com/vi/hgnE7MKi3Y4/maxresdefault.jpg" alt="Bye Limit! Cara Bikin Sistem 'AI Unlimited' 100% Gratis Dengan 9Router!
" width="300"/>
</a><br/>
<b>🇮🇩 Indonesia</b><br/>
<sub>Bye Limit! Cara Bikin Sistem "AI Unlimited" 100% Gratis Dengan 9Router!<br/>by <a href="https://www.youtube.com/@neptiver">neptiver</a></sub>
</td>
<td align="center" width="320"></td>
<td align="center" width="320"></td>
</tr>

View File

@@ -111,7 +111,7 @@ Any tool supporting OpenAI/Claude-compatible API works.
Full docs, advanced setup, video tutorials & development guide:
- **GitHub**: https://github.com/decolua/9router
- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md
- **Full README**: https://github.com/decolua/9router/blob/master/README.md
- **Website**: https://9router.com
---

View File

@@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.5.81",
"version": "0.5.86",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"

View File

@@ -118,6 +118,31 @@ Cursor/Cline/Any tool:
---
## Cursor / Claude Default Combos
Cursor and Claude Code send **unprefixed** model IDs (`composer-2.5`, `claude-opus-5`, `opus`), while 9Router routes with provider prefixes (`cu/composer-2.5`, `cc/claude-opus-5`). Default combo generators bridge that gap.
On **Dashboard → Combos**:
1. Click **Cursor Default** or **Claude Default**
2. Confirm the preview (new vs already-existing names)
3. 9Router creates one combo per client model ID, seeded with the matching prefixed route
**Examples:**
| Combo name (what the client sends) | Seeded model (what 9Router routes) |
|------------------------------------|------------------------------------|
| `composer-2.5` | `cu/composer-2.5` |
| `cursor-grok-4.6-high-fast` | `cu/cursor-grok-4.6-high-fast` |
| `claude-opus-5` | `cc/claude-opus-5` |
| `opus` | `cc/claude-opus-5` |
Existing combo names are **skipped** (not overwritten). Edit any generated combo afterward to add fallbacks. Click the button again later to pick up new catalog IDs.
> These combos help when Cursor/Claude already talk to 9Router (`/v1` or `ANTHROPIC_BASE_URL`) and send their native model IDs. They do not change Cursor’s built-in Models tab by themselves.
---
## Example Combos
### Example 1: Premium Coding (Subscription → Cheap → Free)

View File

@@ -75,6 +75,10 @@ const nextConfig = {
source: "/responses",
destination: "/api/v1/responses"
},
{
source: "/systemone",
destination: "/api/v1/systemone"
},
{
source: "/v1beta/:path*",
destination: "/api/v1beta/:path*"

View File

@@ -4,7 +4,7 @@ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM cha
## Request lifecycle (chat)
`handlers/chatCore.js` → `services/model.js` `parseModel` (resolve `provider/model`) → **pre-translate hooks** (`rtk/` tool_result compress, `rtk/headroom.js` proxy compress, `rtk/caveman.js` system inject — all fail-open) → `executors/index.js` `getExecutor(provider)` → `translator/index.js` `translateRequest` (client format → provider format) → `executor.execute()` (streams upstream) → `translateResponse` (provider chunks → client format) → SSE out.
`handlers/chatCore.js` → `services/model.js` `parseModel` (resolve `provider/model`) → **RTK for `cursor`** (`rtk/` compresses the source-format `tool_result` / `role:tool` in-place — its translator rewrites those shapes, so this one provider must run **before** translate) → `translator/index.js` `translateRequest` (client format → provider format) → **post-translate savers** (`rtk/` compress for every other provider, `rtk/headroom.js` proxy compress, `rtk/caveman.js` / `rtk/ponytail.js` system inject — all fail-open) → `executors/index.js` `getExecutor(provider)` → `executor.execute()` (streams upstream) → `translateResponse` (provider chunks → client format) → SSE out.
## Directory map

View File

@@ -53,7 +53,7 @@ export function findModelName(aliasOrId, modelId) {
}
export function getModelTargetFormat(aliasOrId, modelId) {
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go") && isMuseSparkModel(modelId)) {
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go" || aliasOrId === "ocz" || aliasOrId === "opencode-zen") && isMuseSparkModel(modelId)) {
return FORMATS.OPENAI_RESPONSES;
}
const models = PROVIDER_MODELS[aliasOrId];

View File

@@ -293,12 +293,19 @@ export class AntigravityExecutor extends BaseExecutor {
this._lastSessionId = transformedRequest.sessionId; // cached for buildHeaders (base.execute order)
// Official Antigravity client omits `requestType` entirely on the agent
// (chat) path. Sending `requestType: "agent"` here (or leaking it through
// from an upstream envelope via the ...body spread below) makes Google
// bucket the request and return a detail-free 429 RESOURCE_EXHAUSTED even
// with quota available. `image_gen` and
// `search` buckets are unaffected and keep their own requestType.
delete body.requestType;
return {
...body,
project: projectId,
model: body.model || model,
userAgent: "antigravity",
requestType: "agent",
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
request: transformedRequest
};

View File

@@ -7,13 +7,16 @@ import {
wrapConnectRPCFrame,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse
extractTextFromResponse,
encodeMcpTools,
decodeMcpArgs,
} from "../utils/cursorProtobuf.js";
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
import { estimateUsage } from "../utils/usageTracking.js";
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
import { chatChunkSse, sseChunk } from "../utils/sse.js";
import { FORMATS } from "../translator/formats.js";
import { ROLE, OPENAI_BLOCK } from "../translator/schema/index.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import zlib from "zlib";
import crypto from "crypto";
@@ -65,55 +68,74 @@ function textFromContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((part) => part?.type === "text" && typeof part.text === "string")
.filter((part) => part?.type === OPENAI_BLOCK.TEXT && typeof part.text === "string")
.map((part) => part.text)
.join("\n");
}
function isAgentTextRequest(body) {
// Many compatible clients always attach their built-in tool schemas, even
// for a normal text turn. Cursor's retired ChatService rejects those
// requests; AgentService can still answer the text turn, so ignore schemas
// here. A real tool-call/result conversation is kept on the legacy path
// until its AgentService tool protocol is implemented.
return Array.isArray(body?.messages) && body.messages.every((message) => {
if (message?.tool_calls?.length || message?.role === "tool") return false;
return typeof message?.content === "string"
|| Array.isArray(message?.content) && message.content.every((part) => part?.type === "text");
function isTextPart(part) {
return !part || part.type === OPENAI_BLOCK.TEXT || typeof part === "string";
}
export function isAgentCapableRequest(body) {
// ChatService rejects auto/composer and most thinking variants. AgentService
// can answer text turns (including declared tool schemas) and tool-call
// history. Image parts still need the legacy protobuf path.
if (!Array.isArray(body?.messages) || body.messages.length === 0) return false;
return body.messages.every((message) => {
if (Array.isArray(message?.content)) return message.content.every(isTextPart);
return message?.content == null || typeof message.content === "string";
});
}
function encodeHistoryMessage(message) {
const content = textFromContent(message?.content);
if (!content) return null;
const extras = [];
if (message?.role === ROLE.ASSISTANT && message.tool_calls?.length) {
for (const tc of message.tool_calls) {
extras.push(`[tool_call id=${tc.id || ""} name=${tc.function?.name || "tool"} args=${tc.function?.arguments || "{}"}]`);
}
}
if (message?.role === ROLE.TOOL) {
extras.push(`[tool_result id=${message.tool_call_id || ""}]`);
}
const textBody = [content, ...extras].filter(Boolean).join("\n");
if (!textBody) return null;
// ConversationHistoryMessage.user / .assistant -> repeated content -> text.
const text = agentString(1, content);
if (message.role === "assistant") {
const text = agentString(1, textBody);
if (message.role === ROLE.ASSISTANT) {
return agentMessage(2, agentMessage(1, agentMessage(1, text)));
}
return agentMessage(1, agentMessage(1, agentMessage(1, text)));
}
function buildAgentRunFrame(messages, model) {
export function buildAgentRunFrame(messages, model, tools = []) {
// custom_system_prompt (RunRequest field 8) makes AgentService return an
// empty turn. Fold system text into the current user message instead.
const system = messages
.filter((message) => message?.role === "system")
.filter((message) => message?.role === ROLE.SYSTEM)
.map((message) => textFromContent(message.content))
.filter(Boolean)
.join("\n\n");
const chatMessages = messages.filter((message) => message?.role !== "system");
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user");
const chatMessages = messages.filter((message) => message?.role !== ROLE.SYSTEM);
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf(ROLE.USER);
const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1);
const history = chatMessages
.slice(0, currentIndex >= 0 ? currentIndex : -1)
.map(encodeHistoryMessage)
.filter(Boolean);
const userText = textFromContent(current?.content) || "Continue.";
const rawUser = textFromContent(current?.content) || "Continue.";
const userText = system ? `${system}\n\n${rawUser}` : rawUser;
// agent.v1.UserMessageAction.user_message and its optional history.
// selected_context (3) + mode=1 (4) match cursor-agent's wire format; without
// them the server may accept the RPC and stream an empty turn.
const userMessage = concatBuffers(
agentString(1, userText),
agentString(2, crypto.randomUUID()),
agentMessage(3, new Uint8Array()),
encodeField(4, PROTOBUF_VARINT, 1),
);
const conversationHistory = history.length
? concatBuffers(...history.map((entry) => agentMessage(1, entry)))
@@ -124,11 +146,20 @@ function buildAgentRunFrame(messages, model) {
);
const conversationAction = agentMessage(1, userAction);
const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true));
// ModelDetails (field 3): thinking variants (Composer, Grok, *-thinking)
// return an empty turn when only RequestedModel (field 9) is set.
const modelDetails = concatBuffers(
agentString(1, model),
agentString(3, model),
agentString(4, model),
);
const mcpTools = encodeMcpTools(tools);
const runRequest = concatBuffers(
// An empty ConversationStateStructure starts a fresh local agent session.
agentMessage(1, new Uint8Array()),
agentMessage(2, conversationAction),
...(system ? [agentString(8, system)] : []),
agentMessage(3, modelDetails),
...(mcpTools.length ? [agentMessage(4, mcpTools)] : []),
agentMessage(9, requestedModel),
);
@@ -157,13 +188,51 @@ function decodeAgentFrames(buffer, onFrame) {
return pending;
}
function createRequestContextResponse() {
// AgentService asks every run for client context. 9router has no IDE file
// context, so acknowledge with an empty RequestContext.
function execIds(execRequest) {
const id = Number(execRequest?.get(1)?.[0]?.value || 0);
const execId = extractAgentString(execRequest, 15);
return { id, execId };
}
function wrapExecClientMessage(execMsgId, execId, resultField, resultPayload) {
const parts = [];
if (execMsgId) parts.push(encodeField(1, PROTOBUF_VARINT, execMsgId));
parts.push(agentString(15, execId || ""));
parts.push(encodeField(resultField, PROTOBUF_LEN, resultPayload || new Uint8Array()));
return wrapConnectRPCFrame(agentMessage(2, concatBuffers(...parts)));
}
function createRequestContextResponse(execRequest) {
// Tools already go out on AgentRunRequest.mcp_tools. Echoing them again on
// this ack makes AgentService stall silently (0 SSE bytes until abort).
const { id, execId } = execIds(execRequest);
const requestContextSuccess = agentMessage(1, new Uint8Array());
const requestContextResult = agentMessage(1, requestContextSuccess);
const execClientMessage = agentMessage(10, requestContextResult);
return wrapConnectRPCFrame(agentMessage(2, execClientMessage));
return wrapExecClientMessage(id, execId, 10, requestContextResult);
}
// ExecServerMessage variant → ExecClientMessage result field (same numbers).
const EXEC_RESULT_FIELD = {
2: 2, 3: 3, 4: 4, 5: 5, 7: 7, 8: 8, 9: 9, 16: 16, 20: 20, 23: 23,
};
function rejectExecRequest(execRequest) {
const { id, execId } = execIds(execRequest);
const variant = [...(execRequest?.keys?.() || [])].find((field) => field !== 1 && field !== 15);
const resultField = EXEC_RESULT_FIELD[variant];
if (!resultField) return null;
// Diagnostics has no rejected variant — empty success unblocks the stream.
if (variant === 9) return wrapExecClientMessage(id, execId, 9, new Uint8Array());
const rejected = agentMessage(2, agentString(2, "Tool not available in this environment. Use the MCP tools provided instead."));
return wrapExecClientMessage(id, execId, resultField, rejected);
}
function encodeKvClientMessage(kvId, resultField, resultPayload, metadata) {
const parts = [];
if (kvId) parts.push(encodeField(1, PROTOBUF_VARINT, kvId));
parts.push(encodeField(resultField, PROTOBUF_LEN, resultPayload || new Uint8Array()));
if (metadata && metadata.length) parts.push(encodeField(4, PROTOBUF_LEN, metadata));
return wrapConnectRPCFrame(agentMessage(3, concatBuffers(...parts)));
}
const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1";
@@ -479,7 +548,7 @@ export class CursorExecutor extends BaseExecutor {
};
}
async executeAgent({ model, body, stream, credentials, signal }) {
async executeAgent({ model, body, stream, credentials, signal, log }) {
const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint;
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
@@ -491,9 +560,10 @@ export class CursorExecutor extends BaseExecutor {
}
let session;
const tools = body.tools || [];
try {
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
session.write(buildAgentRunFrame(body.messages || [], model));
session.write(buildAgentRunFrame(body.messages || [], model, tools));
} catch (error) {
throw new Error(`Cursor AgentService request failed: ${error.message}`);
}
@@ -533,8 +603,23 @@ export class CursorExecutor extends BaseExecutor {
// so strict clients such as Claude Code accept the completed stream.
const responseId = `chatcmpl-msg_${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const composerModel = isComposerModel(model);
let pending = Buffer.alloc(0);
let finished = false;
let thinkingAcc = "";
let emittedVisible = 0;
let emittedText = false;
const flushThinkingFallback = (onEvent) => {
if (emittedText || !thinkingAcc) return;
const fallback = composerModel
? visibleComposerContentFromThinking(thinkingAcc)
: thinkingAcc.trim();
if (fallback) {
emittedText = true;
onEvent({ type: "text", value: fallback });
}
};
const consume = async (onEvent) => {
try {
@@ -553,32 +638,87 @@ export class CursorExecutor extends BaseExecutor {
const update = decodeMessage(serverMessage.get(1)[0].value);
if (update.has(1)) {
const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1);
if (textDelta) onEvent({ type: "text", value: textDelta });
if (textDelta) {
emittedText = true;
onEvent({ type: "text", value: textDelta });
}
}
// Cursor's AgentService emits internal reasoning without the
// cryptographic signature required by Anthropic thinking blocks.
// Forwarding it makes strict Anthropic clients (Claude Code)
// discard or wait on an otherwise complete response. Keep the
// reasoning upstream-only and emit the normal answer text.
// thinking_delta (field 4). Composer (and some Grok variants) put
// the visible answer after </think> here and never send text_delta.
if (update.has(4)) {
const thinkingDelta = extractAgentString(decodeMessage(update.get(4)[0].value), 1);
if (thinkingDelta) {
thinkingAcc += thinkingDelta;
if (composerModel) {
const visible = visibleComposerContentFromThinking(thinkingAcc);
if (visible.length > emittedVisible) {
const deltaContent = visible.slice(emittedVisible);
emittedVisible = visible.length;
emittedText = true;
onEvent({ type: "text", value: deltaContent });
}
}
}
}
// Keep unsigned reasoning upstream-only for Anthropic clients.
if (update.has(14)) {
flushThinkingFallback(onEvent);
finished = true;
onEvent({ type: "done" });
}
}
// KvServerMessage (field 4): get/set blob. Ack so the stream proceeds.
if (serverMessage.has(4)) {
const kv = decodeMessage(serverMessage.get(4)[0].value);
const kvId = kv.get(1)?.[0]?.value || 0;
const metadata = kv.get(4)?.[0]?.value || null;
if (kv.has(2)) {
session.write(encodeKvClientMessage(kvId, 2, agentMessage(1, new Uint8Array()), metadata));
} else if (kv.has(3)) {
session.write(encodeKvClientMessage(kvId, 3, new Uint8Array(), metadata));
}
}
// AgentService requests IDE context before producing a response.
// Return an empty context; 9router is not coupled to an editor.
if (serverMessage.has(2)) {
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
if (execRequest.has(10)) {
session.write(createRequestContextResponse());
log?.info?.("CURSOR", "AgentService request_context ack");
session.write(createRequestContextResponse(execRequest));
} else if (execRequest.has(11)) {
const mcp = decodeMcpArgs(execRequest.get(11)[0].value);
const name = mcp.toolName || mcp.name;
if (name) {
log?.info?.("CURSOR", `AgentService MCP tool_call ${name}`);
finished = true;
onEvent({
type: "tool_call",
value: {
id: mcp.toolCallId || `call_${crypto.randomUUID()}`,
name,
arguments: JSON.stringify(mcp.args || {}),
},
});
onEvent({ type: "done", finishReason: "tool_calls" });
} else {
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
finished = true;
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
}
} else {
// Every other ExecServerMessage variant is an editor-backed tool
// (shell, read, write, …) that 9router cannot service. Fail the
// turn rather than narrating protocol state as assistant text.
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
finished = true;
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
// Auto/Composer often probe IDE builtins (shell/read/…). Reject
// them so the model can continue with MCP tools or a text answer
// instead of stalling the h2 stream.
const rejection = rejectExecRequest(execRequest);
if (rejection) {
log?.info?.("CURSOR", `AgentService rejected IDE exec fields=${[...execRequest.keys()].join(",")}`);
session.write(rejection);
} else {
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
finished = true;
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
}
}
}
});
@@ -586,7 +726,10 @@ export class CursorExecutor extends BaseExecutor {
} finally {
try { session.end(); } catch {}
try { session.close(); } catch {}
if (!finished) onEvent({ type: "done" });
if (!finished) {
flushThinkingFallback(onEvent);
onEvent({ type: "done" });
}
}
};
@@ -594,10 +737,21 @@ export class CursorExecutor extends BaseExecutor {
let content = "";
let reasoning = "";
let agentError = null;
const toolCalls = [];
let finishReason = "stop";
await consume((event) => {
if (event.type === "text") content += event.value;
else if (event.type === "thinking") reasoning += event.value;
else if (event.type === "tool_call") {
toolCalls.push({
id: event.value.id,
type: "function",
function: { name: event.value.name, arguments: event.value.arguments },
});
finishReason = "tool_calls";
}
else if (event.type === "error") agentError = event.value;
else if (event.type === "done" && event.finishReason) finishReason = event.finishReason;
});
if (agentError) {
return {
@@ -611,13 +765,19 @@ export class CursorExecutor extends BaseExecutor {
responseFormat: FORMATS.OPENAI,
};
}
const message = {
role: "assistant",
content: content || null,
...(reasoning ? { reasoning_content: reasoning } : {}),
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
};
return {
response: new Response(JSON.stringify({
id: responseId,
object: "chat.completion",
created,
model,
choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }],
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: estimateUsage(body, content.length, FORMATS.OPENAI),
}), { headers: { "Content-Type": "application/json" } }),
url,
@@ -635,6 +795,18 @@ export class CursorExecutor extends BaseExecutor {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
} else if (event.type === "thinking") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
} else if (event.type === "tool_call") {
controller.enqueue(encoder.encode(chatChunkSse({
id: responseId, created, model,
delta: {
tool_calls: [{
index: 0,
id: event.value.id,
type: "function",
function: { name: event.value.name, arguments: event.value.arguments },
}],
},
})));
} else if (event.type === "error") {
// An SSE error frame, not a content delta: a protocol failure must not
// be rendered to the user as the assistant's reply, and downstream
@@ -643,7 +815,10 @@ export class CursorExecutor extends BaseExecutor {
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
} else if (event.type === "done") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
controller.enqueue(encoder.encode(chatChunkSse({
id: responseId, created, model, delta: {},
finishReason: event.finishReason || "stop",
})));
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
}
@@ -664,9 +839,9 @@ export class CursorExecutor extends BaseExecutor {
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
if (isAgentTextRequest(body)) {
if (isAgentCapableRequest(body)) {
try {
return await this.executeAgent({ model, body, stream, credentials, signal });
return await this.executeAgent({ model, body, stream, credentials, signal, log });
} catch (error) {
return {
response: new Response(JSON.stringify({

View File

@@ -11,6 +11,7 @@ import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js";
import { OpenCodeExecutor } from "./opencode.js";
import { OpenCodeGoExecutor } from "./opencode-go.js";
import { OpenCodeZenExecutor } from "./opencode-zen.js";
import { GrokWebExecutor } from "./grok-web.js";
import { GrokCliExecutor } from "./grok-cli.js";
import { PerplexityWebExecutor } from "./perplexity-web.js";
@@ -34,6 +35,7 @@ const executors = {
github: new GithubExecutor(),
iflow: new IFlowExecutor(),
qoder: new QoderExecutor(),
"qoder-cn": new QoderExecutor("qoder-cn"),
kiro: new KiroExecutor(),
kimchi: new KimchiExecutor(),
codex: new CodexExecutor(),
@@ -43,6 +45,7 @@ const executors = {
"vertex-partner": new VertexExecutor("vertex-partner"),
opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"opencode-zen": new OpenCodeZenExecutor(),
"grok-web": new GrokWebExecutor(),
"grok-cli": new GrokCliExecutor(),
gcli: new GrokCliExecutor(), // Alias
@@ -89,6 +92,7 @@ export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.js";
export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js";
export { OpenCodeZenExecutor } from "./opencode-zen.js";
export { GrokWebExecutor } from "./grok-web.js";
export { GrokCliExecutor } from "./grok-cli.js";
export { PerplexityWebExecutor } from "./perplexity-web.js";

View File

@@ -0,0 +1,315 @@
import crypto from "node:crypto";
import { DefaultExecutor } from "./default.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { isMuseSparkModel } from "../providers/models/helpers.js";
import {
normalizeResponsesInput,
clampResponsesCallId,
coerceResponsesArguments,
coerceResponsesOutput,
} from "../translator/formats/responsesApi.js";
const SESSION_HEADER = "x-opencode-session";
const SESSION_FIELD = "_opencodeZenSession";
const MAX_SESSION_LENGTH = 256;
const RESPONSES_BASE_URL = "https://opencode.ai/zen/v1/responses";
const MAX_TOOL_NAME_LEN = 128;
const OPENCODE_UA = "opencode/1.18.31";
export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// Free-tier fingerprint (mirrors opencode executor, PR #4132): upstream 403s
// requests without the file-search quartet and without stream:true.
const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"];
function hasValidOpencodeVersion(ua) {
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
if (!m) return false;
const major = parseInt(m[1], 10);
const minor = parseInt(m[2], 10);
return major > 1 || (major === 1 && minor >= 17);
}
function unstableRandom() {
const bytes = crypto.randomBytes(14);
let randomPart = "";
for (let i = 0; i < 14; i++) {
randomPart += BASE62_CHARS[bytes[i] % 62];
}
return randomPart;
}
export function generateSessionId(timestamp = Date.now()) {
const current = BigInt(timestamp) * 0x1000n + 1n;
const value = ~current;
const time = Array.from({ length: 6 }, (_, index) =>
Number((value >> BigInt(40 - 8 * index)) & 0xffn)
.toString(16)
.padStart(2, "0")
).join("");
return `ses_${time}${unstableRandom()}`;
}
export function generateRequestId(timestamp = Date.now()) {
const current = BigInt(timestamp) * 0x1000n + 1n;
const value = current;
const time = Array.from({ length: 6 }, (_, index) =>
Number((value >> BigInt(40 - 8 * index)) & 0xffn)
.toString(16)
.padStart(2, "0")
).join("");
return `msg_${time}${unstableRandom()}`;
}
export function translateSessionId(sessionId, clientTool = "") {
if (typeof sessionId === "string" && OPENCODE_SESSION_RE.test(sessionId.trim())) {
return sessionId.trim();
}
const digest = crypto
.createHash("sha256")
.update(`opencode\0${clientTool || "generic"}\0${sessionId || ""}`)
.digest();
const timeHex = digest.subarray(0, 6).toString("hex");
let randomPart = "";
for (let i = 6; i < 20; i++) {
randomPart += BASE62_CHARS[digest[i] % 62];
}
return `ses_${timeHex}${randomPart}`;
}
function toolNameOf(tool) {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null;
const raw = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : "");
return raw.trim();
}
function ensureChatFingerprintTools(body) {
if (!body || typeof body !== "object") return;
const present = new Set();
if (Array.isArray(body.tools)) {
for (const tool of body.tools) {
const name = toolNameOf(tool);
if (name) present.add(name);
}
} else {
body.tools = [];
}
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
if (present.has(name)) continue;
body.tools.push({
type: "function",
function: {
name,
description: `OpenCode built-in ${name} tool`,
parameters: { type: "object", properties: {} },
},
});
present.add(name);
}
}
function ensureResponsesFingerprintTools(body) {
if (!body || typeof body !== "object") return;
const present = new Set();
if (Array.isArray(body.tools)) {
for (const tool of body.tools) {
const name = toolNameOf(tool);
if (name) present.add(name);
}
} else {
body.tools = [];
}
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
if (present.has(name)) continue;
body.tools.push({
type: "function",
name,
description: `OpenCode built-in ${name} tool`,
parameters: { type: "object", properties: {} },
});
present.add(name);
}
}
function normalizeSession(value) {
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null;
return normalized;
}
function nativeSession(headers) {
if (!headers || typeof headers !== "object") return null;
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === SESSION_HEADER) {
const normalized = normalizeSession(value);
if (normalized && OPENCODE_SESSION_RE.test(normalized)) return normalized;
}
}
return null;
}
function translatedSession(sessionId, clientTool) {
return translateSessionId(sessionId, clientTool);
}
// Strip the thinking suffix "model(level)" so checks hit the base id.
function baseModelId(model) {
return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
}
function isResponsesModel(model) {
return isMuseSparkModel(baseModelId(model));
}
// Flatten Chat Completions tool declarations into the Responses flat shape and
// drop hosted/nameless tools the /responses endpoint rejects.
function normalizeResponsesTools(body) {
if (!Array.isArray(body.tools)) return;
const validNames = new Set();
body.tools = body.tools.filter((tool) => {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null;
const rawName = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : "");
const name = rawName.trim();
if (!name) return false;
const description = typeof tool.description === "string" ? tool.description : (typeof fn?.description === "string" ? fn.description : "");
let parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters))
? tool.parameters
: (fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} });
// Mirror the request translator: {type:"object"} without properties is rejected
// by strict Responses backends, so fill in the empty properties map.
if (parameters.type === "object" && !parameters.properties) parameters = { ...parameters, properties: {} };
for (const k of Object.keys(tool)) delete tool[k];
tool.type = "function";
tool.name = name.slice(0, MAX_TOOL_NAME_LEN);
if (description) tool.description = description;
tool.parameters = parameters;
validNames.add(tool.name);
return true;
});
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
if (body.tool_choice.type === "function") {
const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : "";
if (!n || !validNames.has(n)) delete body.tool_choice;
}
}
}
// Last line of defense for native Responses clients (sourceFormat === targetFormat
// skips translation): coerce items in place so malformed tool payloads 400 here
// with a clear shape instead of upstream as InputValidationError.
function sanitizeResponsesItems(body) {
if (!Array.isArray(body.input)) return;
body.input = body.input.filter((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return true;
// Strip prior-turn reasoning items: Muse Spark contributor models route to
// an upstream Console backend where encrypted_content cannot be validated across
// rotated accounts or sessions, causing 400 "reasoning encrypted_content was not issued to this caller".
if (item.type === "reasoning") return false;
delete item.encrypted_content;
delete item.reasoning_encrypted_content;
if (item.type === "function_call") {
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") return false;
item.name = item.name.trim().slice(0, MAX_TOOL_NAME_LEN);
item.call_id = clampResponsesCallId(item.call_id);
item.arguments = coerceResponsesArguments(item.arguments);
return true;
}
if (item.type === "function_call_output") {
item.call_id = clampResponsesCallId(item.call_id);
item.output = coerceResponsesOutput(item.output);
return true;
}
return true;
});
}
export class OpenCodeZenExecutor extends DefaultExecutor {
constructor() {
super("opencode-zen");
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
// Muse Spark lives on /responses even when a stale runtimeTransport leaks in.
if (isResponsesModel(model)) return RESPONSES_BASE_URL;
return super.buildUrl(model, stream, urlIndex, credentials);
}
prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) {
const sourceCredentials = credentials || {};
const native = nativeSession(sourceCredentials.rawHeaders);
const resolved = normalizeSession(providerSessionId) || resolveSessionId({
headers: sourceCredentials.rawHeaders,
body,
connectionId: sourceCredentials.connectionId,
scope: "opencode-zen",
});
return {
...sourceCredentials,
[SESSION_FIELD]: native || translatedSession(resolved, clientTool),
};
}
async execute(args) {
const credentials = this.prepareRequestCredentials(args);
return super.execute({ ...args, credentials });
}
buildHeaders(credentials, stream = true, url, model) {
const headers = super.buildHeaders(credentials || {}, stream, url, model);
const raw = credentials?.rawHeaders || {};
const lower = {};
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
const downstreamUa = lower["user-agent"] || "";
// Free-tier gate: spoof the official client UA.
headers["User-Agent"] = hasValidOpencodeVersion(downstreamUa) ? downstreamUa : OPENCODE_UA;
headers["x-opencode-client"] = lower["x-opencode-client"] || "desktop";
const prepared = credentials?.[SESSION_FIELD];
if (prepared) {
headers[SESSION_HEADER] = prepared;
return headers;
}
const fallback = this.prepareRequestCredentials({ credentials });
headers[SESSION_HEADER] = fallback[SESSION_FIELD];
return headers;
}
transformRequest(model, body, stream, credentials) {
const out = super.transformRequest(model, body);
// Free-tier gate: upstream 403s stream:false even when everything else is valid.
if (out && typeof out === "object") out.stream = true;
if (!isResponsesModel(model || body?.model)) {
ensureChatFingerprintTools(out);
return out;
}
const normalized = normalizeResponsesInput(out.input);
if (normalized) out.input = normalized;
if (!Array.isArray(out.input) || out.input.length === 0) {
out.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
}
// Responses names the output cap max_output_tokens, not max_tokens.
if (out.max_output_tokens === undefined) {
if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens;
else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens;
}
delete out.max_tokens;
delete out.max_completion_tokens;
if (out.reasoning_effort !== undefined && out.reasoning === undefined) {
out.reasoning = { effort: out.reasoning_effort, summary: "auto" };
}
if (out.reasoning && typeof out.reasoning === "object" && !Array.isArray(out.reasoning)) {
if (!out.reasoning.summary) out.reasoning.summary = "auto";
}
delete out.reasoning_effort;
out.stream = true;
out.store = false;
ensureResponsesFingerprintTools(out);
normalizeResponsesTools(out);
sanitizeResponsesItems(out);
return out;
}
}

View File

@@ -6,6 +6,7 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { isMuseSparkModel } from "../providers/models/helpers.js";
import { applyFingerprintTools } from "../utils/opencodeFingerprint.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
import {
normalizeResponsesInput,
@@ -24,68 +25,6 @@ export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
// OpenCode free tier requires both 'bash' and 'read' in tools payload.
// Injected as cloaked decoy tools so external CLI tools (e.g. Claude Code's Bash/Read)
// take precedence while satisfying upstream verification.
const OPENCODE_DECOY_CHAT_TOOLS = [
{
type: "function",
function: {
name: "bash",
description: "This tool is currently unavailable and must not be used.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "read",
description: "This tool is currently unavailable and must not be used.",
parameters: { type: "object", properties: {} },
},
},
];
const OPENCODE_DECOY_RESPONSES_TOOLS = [
{
type: "function",
name: "bash",
description: "This tool is currently unavailable and must not be used.",
parameters: { type: "object", properties: {} },
},
{
type: "function",
name: "read",
description: "This tool is currently unavailable and must not be used.",
parameters: { type: "object", properties: {} },
},
];
function cloakOpencodeTools(body, isResponses) {
if (!body || typeof body !== "object") return;
if (isResponses) {
if (!Array.isArray(body.tools)) body.tools = [];
const names = new Set(body.tools.map((t) => t.name || t.function?.name));
for (const tool of OPENCODE_DECOY_RESPONSES_TOOLS) {
if (!names.has(tool.name)) body.tools.push({ ...tool });
}
if (!body.tool_choice) body.tool_choice = "auto";
} else {
const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
if (!hasTools) {
body.tools = OPENCODE_DECOY_CHAT_TOOLS.map((t) => ({ ...t, function: { ...t.function } }));
if (!body.tool_choice) body.tool_choice = "none";
} else {
const names = new Set(body.tools.map((t) => t.function?.name || t.name));
for (const tool of OPENCODE_DECOY_CHAT_TOOLS) {
if (!names.has(tool.function.name)) {
body.tools.push({ ...tool, function: { ...tool.function } });
}
}
}
}
}
function hasValidOpencodeVersion(ua) {
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
if (!m) return false;
@@ -499,11 +438,12 @@ export class OpenCodeExecutor extends BaseExecutor {
body.store = false;
normalizeResponsesTools(body);
sanitizeResponsesItems(body);
if (!Array.isArray(body.tools) || body.tools.length === 0) {
cloakOpencodeTools(body, true);
}
// Free-tier fingerprint tools are required even when an agent client
// already supplied tools. ZCode/Claude Code requests normally have
// non-empty tool arrays; skipping cloaking here triggers 403 FreeTierError.
applyFingerprintTools(body, true);
} else if (body && typeof body === "object") {
cloakOpencodeTools(body, false);
applyFingerprintTools(body, false);
}
return injectReasoningContent({ provider: this.provider, model, body });
}

View File

@@ -29,7 +29,7 @@ import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { FETCH_CONNECT_TIMEOUT_MS, HTTP_STATUS } from "../config/runtimeConfig.js";
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
import {
QODER_CHAT_SIG_PATH,
@@ -208,16 +208,16 @@ function truncate(s, n) {
/**
* Map the OpenAI-style request body into the exact shape Qoder expects.
*/
async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, uploadFn = null }) {
async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, uploadFn = null, region = "intl" }) {
const qoderKey = String(model || "").replace(/^qoder\//, "");
// Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP.
// This allows support for new Qoder models (e.g., qmodel_latest) without code changes.
let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal });
let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal, region });
if (!modelConfig) {
// Try a forced refresh once before giving up — the cache may simply
// not be populated yet on first ever call for this credential.
const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal });
const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal, region });
const retried = refreshed?.rawConfigs.get(qoderKey);
if (!retried) {
throw new Error(
@@ -337,47 +337,65 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
/**
* Check if a qoder error message indicates a billing/quota block.
* Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field.
* Signatures: code 110 (billing daily count exceeded), code 112 (quota
* exhausted), code 10605 (queue throttle), pricingUrl field.
*/
function isBillingBlock(inner) {
if (!inner || typeof inner !== "string") return false;
const lowerMsg = inner.toLowerCase();
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
if (lowerMsg.includes("pricingurl")) return true;
// Parsed code preferred over regex: matches numeric or string "110"/"112"/"10605".
try {
const parsed = JSON.parse(inner);
const code = String(parsed?.code ?? "");
if (code === "110" || code === "112" || code === "10605") return true;
} catch { /* not JSON — fall through to legacy shape match */ }
// Match legacy exact shapes: {"code":"112",...}, {"code":"10605",...}.
return /"code"\s*:\s*"(112|10605)"/.test(inner);
}
/**
* Peek the first SSE frame to detect billing errors before piping.
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
* Peek the first SSE data line to detect upstream errors before piping.
* Returns { isError, isBilling, statusVal, message, consumed } — `consumed` is every
* byte read so far (including the peeked line) so the caller can re-process
* it and nothing is dropped from the stream.
*/
async function peekFirstQoderFrame(reader, decoder) {
let consumed = "";
let offset = 0;
let upstreamDone = false;
while (true) {
const { done, value } = await reader.read();
if (done) return { isBilling: false, consumed, upstreamDone: true };
let nl = consumed.indexOf("\n", offset);
if (nl === -1 && !upstreamDone) {
const { done, value } = await reader.read();
upstreamDone = done;
consumed += done ? decoder.decode() : decoder.decode(value, { stream: true });
continue;
}
if (offset >= consumed.length) return { isError: false, consumed, upstreamDone };
if (nl === -1) nl = consumed.length;
consumed += decoder.decode(value, { stream: true });
const nl = consumed.indexOf("\n");
if (nl === -1) continue; // need a full line first
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
const line = consumed.slice(offset, nl).replace(/\r$/, "").trim();
offset = nl + 1;
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trimStart();
if (data === "[DONE]") return { isBilling: false, consumed };
if (data === "[DONE]") return { isError: false, consumed, upstreamDone };
let envelope;
try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; }
try { envelope = JSON.parse(data); } catch { return { isError: false, consumed, upstreamDone }; }
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const inner = typeof envelope.body === "string" ? envelope.body : "";
// statusCodeValue is documented numeric, but accept numeric strings defensively.
const raw = Number(envelope?.statusCodeValue);
const statusVal = Number.isNaN(raw) ? 200 : raw;
const inner = typeof envelope?.body === "string"
? envelope.body
: envelope?.body != null ? JSON.stringify(envelope.body) : "";
if (statusVal !== 200 && isBillingBlock(inner)) {
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
if (statusVal !== 200) {
return { isError: true, isBilling: isBillingBlock(inner), statusVal, message: inner || `upstream status ${statusVal}` };
}
return { isBilling: false, consumed };
return { isError: false, consumed, upstreamDone };
}
}
@@ -388,8 +406,8 @@ async function peekFirstQoderFrame(reader, decoder) {
* Each upstream line looks like:
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
* and re-emit as `data: <inner>\n\n`. Errors become a synthetic OpenAI error
* chunk + [DONE].
* and re-emit as `data: <inner>\n\n`. First-frame errors become HTTP errors;
* errors after streaming starts retain the synthetic chunk + [DONE] path.
*
* Critical: Qoder's SSE often keeps the socket open after the terminal
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
@@ -401,24 +419,28 @@ async function peekFirstQoderFrame(reader, decoder) {
* usage from the finish chunk, so we coalesce those two frames (see
* createQoderSseCoalescer) before forwarding.
*
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
* If detected, return 403 response so chatCore marks connection unavailable
* and triggers combo fallback instead of leaking error text into chat.
* Peek the first frame for errors before committing to HTTP 200. Preserve
* upstream error statuses so chatCore can handle failures instead of recording
* error text as a successful completion. Billing blocks retain the existing
* 403 mapping for quota/account fallback.
*/
async function wrapQoderSSE(response, model) {
async function wrapQoderSSE(response, model, log = null) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const reader = response.body.getReader();
// Peek first frame to detect billing block
// Detect errors before returning a successful streaming response.
const peek = await peekFirstQoderFrame(reader, decoder);
if (peek?.isBilling) {
// Billing block detected — return 403 so chatCore fails this connection
if (peek.isError) {
await reader.cancel().catch(() => {});
const status = peek.isBilling
? HTTP_STATUS.FORBIDDEN
: Number.isInteger(peek.statusVal) && peek.statusVal >= HTTP_STATUS.BAD_REQUEST && peek.statusVal <= 599
? peek.statusVal : HTTP_STATUS.BAD_GATEWAY;
return new Response(
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
{ status: 403, headers: { "Content-Type": "application/json" } }
{ status, headers: { "Content-Type": "application/json" } }
);
}
@@ -449,11 +471,35 @@ async function wrapQoderSSE(response, model) {
let envelope;
try { envelope = JSON.parse(data); } catch { return; }
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
const statusVal = Number(envelope.statusCodeValue) || 200;
const inner = typeof envelope.body === "string"
? envelope.body
: envelope.body != null ? JSON.stringify(envelope.body) : "";
if (statusVal !== 200) {
// Always visible: error envelopes are rare and worth one stderr line at
// any log level (response bodies carry no credentials).
try {
console.error(`[QODER] error envelope status=${statusVal} statusType=${typeof envelope.statusCodeValue} bodyType=${typeof envelope.body} body=${truncate(inner, 300)}`);
} catch { /* logging must not break the stream */ }
if (isBillingBlock(inner)) {
// Billing/quota envelope at any stream position (peek only covers the
// first frame): emit a structured error chunk, not fake assistant text.
// parseSSEToOpenAIResponse understands chunk.error and turns it into a
// non-200 result so chat.js locks the model and falls back. Streaming
// clients receive a real SSE error instead of "[qoder error ...]" text.
const errObj = JSON.stringify({
error: {
message: inner || `qoder billing block (${statusVal})`,
code: "qoder_billing_block",
status: 403,
type: "quota_error",
},
});
controller.enqueue(encoder.encode(`data: ${errObj}\n\n`));
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
return;
}
const msg = inner || `upstream status ${statusVal}`;
const errChunk = JSON.stringify({
id: `qoder-error-${Date.now()}`,
@@ -552,12 +598,13 @@ async function wrapQoderSSE(response, model) {
}
export class QoderExecutor extends BaseExecutor {
constructor() {
super("qoder", PROVIDERS.qoder);
constructor(provider = "qoder") {
super(provider, PROVIDERS[provider]);
this.region = provider === "qoder-cn" ? "cn" : "intl";
}
buildUrl(credentials) {
return `${qoderInferenceBase(credentials)}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
return `${qoderInferenceBase(credentials, this.region)}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
}
// Override execute entirely — Qoder needs:
@@ -572,7 +619,7 @@ export class QoderExecutor extends BaseExecutor {
const rawToken = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(rawToken)) {
try {
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal);
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal, this.region);
} catch (err) {
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
const fakeResp = new Response(
@@ -607,7 +654,7 @@ export class QoderExecutor extends BaseExecutor {
let qoderKey;
let payload;
try {
({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }));
({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, region: this.region }));
} catch (err) {
const fakeResp = new Response(
JSON.stringify({ error: { message: err.message } }),
@@ -666,8 +713,15 @@ export class QoderExecutor extends BaseExecutor {
response = await proxyAwareFetch(
url,
{ method: "POST", headers, body: encodedBodyBuf, signal: mergedSignal },
proxyOptions,
// A failed proxy request may already have reached Qoder. Replaying
// the same COSY signature directly reuses its requestId and returns
// 403/code 103. Let the caller retry through execute() with fresh signing.
{ ...proxyOptions, strictProxy: true },
);
} catch (err) {
// strictProxy wraps transport errors; retain caller cancellation semantics.
if (mergedSignal.aborted) throw mergedSignal.reason;
throw err;
} finally {
clearTimeout(connectTimer);
}
@@ -677,7 +731,7 @@ export class QoderExecutor extends BaseExecutor {
return { response, url, headers, transformedBody: payload };
}
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`);
const wrapped = await wrapQoderSSE(response, `${this.provider}/${qoderKey}`, log);
return { response: wrapped, url, headers, transformedBody: payload };
}

View File

@@ -1,10 +1,15 @@
import { DefaultExecutor } from "./default.js";
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js";
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, resolveMimoServerBase, MIMO_API_UA } from "../shared/mimoAccount.js";
// Desktop-exclusive Preview models. These are served by the account service's
// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key).
// See shared/mimoAccount.js for the session handshake.
const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]);
// Dual-route v2.6 models.
// v2.6 models dynamically route to the account service when desktop session credentials
// (mimoPassToken or account cookie) are present to consume weekly quota, falling back to
// the cloud API (sk- key) otherwise.
const ACCOUNT_MODELS = new Set([
"mimo-v2.6-pro",
"mimo-v2.6-flash",
"mimo-v2.6-pro-ultraspeed",
]);
// Session cookie resolved in execute() (async) and read back by buildHeaders()
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
@@ -23,15 +28,24 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
super("xiaomi-mimo");
}
static isPreviewModel(model) {
return PREVIEW_MODELS.has(bareModel(model));
static isAccountRoute(model, credentials) {
const bare = bareModel(model);
if (!ACCOUNT_MODELS.has(bare)) return false;
return Boolean(
credentials?.[COOKIE_KEY] ||
credentials?.providerSpecificData?.mimoPassToken
);
}
isAccountRoute(model, credentials) {
return XiaomiMimoExecutor.isAccountRoute(model, credentials);
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
// Preview models live on the account-service route, which is not one of the
// Account route models live on the account-service route, which is not one of the
// declared transports — resolve it before the default runtimeTransport path.
if (XiaomiMimoExecutor.isPreviewModel(model)) {
return `${MIMO_API_BASE}/api/route/chat/completions`;
if (this.isAccountRoute(model, credentials)) {
return `${resolveMimoServerBase(credentials?.providerSpecificData)}/api/route/chat/completions`;
}
// Cloud API models keep default handling, so a Claude-format client reaches
// the /anthropic/v1/messages transport.
@@ -39,8 +53,8 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
}
buildHeaders(credentials, stream = true, url, model) {
if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) {
// Preview models authenticate with the account-session cookie, not the key.
if (this.isAccountRoute(model, credentials) && credentials?.[COOKIE_KEY]) {
// Account route models authenticate with the account-session cookie, not the key.
return {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
@@ -52,17 +66,22 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
}
transformRequest(model, body, stream, credentials) {
// super runs stripUnsupportedParams, which flattens Preview content-part
// super runs stripUnsupportedParams, which flattens content-part
// arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js).
const out = super.transformRequest(model, body, stream, credentials);
// Preview models: thinking/params get defaults only — never override what the
// caller set explicitly. (body.model is already `xiaomi/<id>` via upstreamModelId.)
if (XiaomiMimoExecutor.isPreviewModel(model)) {
if (out.thinking == null) out.thinking = { type: "enabled" };
// Account route models: bridge reasoning_effort to official output_config.effort
// (matches MiMo Desktop app.asar behavior).
if (this.isAccountRoute(model, credentials)) {
const rawEffort = out.reasoning_effort || body?.reasoning_effort || body?.output_config?.effort;
if (rawEffort) {
delete out.reasoning_effort;
const norm = String(rawEffort).toLowerCase() === "xhigh" ? "high" : String(rawEffort).toLowerCase();
out.output_config = { ...(out.output_config || {}), effort: norm };
}
if (out.temperature == null) out.temperature = 1.0;
if (out.top_p == null) out.top_p = 0.95;
if (!out.max_tokens) out.max_tokens = 4096;
}
return out;
@@ -70,13 +89,11 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
async execute(args) {
const { model, credentials, proxyOptions = null } = args;
if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args);
if (!this.isAccountRoute(model, credentials)) return super.execute(args);
const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions);
if (!cookie) {
throw new Error(
"Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.",
);
return super.execute(args);
}
credentials[COOKIE_KEY] = cookie;
const result = await super.execute(args);
@@ -94,6 +111,6 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
}
}
export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY };
export const __test__ = { ACCOUNT_MODELS, bareModel, COOKIE_KEY };
export default XiaomiMimoExecutor;

View File

@@ -20,6 +20,7 @@ import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
import { dedupeTools } from "../utils/toolDeduper.js";
import { takeRenamedToolNames } from "../utils/opencodeFingerprint.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
@@ -116,6 +117,19 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
}
// Per-request opt-out: client can bypass all token savers via header
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
// Cursor's translator rewrites tool_result into user text, so RTK must run on
// the source body before translation. Every other pair translates the tool
// shapes 1:1 — keep the post-translate pass there so those providers are
// untouched (and a retry never re-compresses an already-compressed body).
const preTranslateRtk = provider === "cursor"
? compressMessages(body, tokenSaverEnabled && rtkEnabled)
: null;
const preTranslateRtkLine = formatRtkLog(preTranslateRtk);
if (preTranslateRtkLine) console.log(preTranslateRtkLine);
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
let stream = providerRequiresStreaming ? true : (body.stream !== false);
@@ -254,11 +268,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
translatedBody.tools = defaultClaudeToolType(translatedBody.tools);
}
// Per-request opt-out: client can bypass all token savers via header
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
// RTK: compress tool_result content
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
// RTK: compress tool_result content. Skipped when already done pre-translate.
const rtkStats = preTranslateRtk || compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
const rtkLine = formatRtkLog(rtkStats);
if (rtkLine) log?.info?.("RTK", rtkLine.replace(/^\[RTK\] /, ""));
@@ -277,6 +288,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Token-saver flags accumulator for the single "⚙" log line below.
const xf = [];
if (rtkStats?.hits?.length) xf.push(`RTK:${rtkStats.hits.length}`);
// Caveman: inject terse-style system prompt
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
@@ -378,6 +391,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
providerHeaders = result.headers;
finalBody = result.transformedBody;
providerResponseFormat = result.responseFormat || targetFormat;
const renamedToolNames = takeRenamedToolNames(translatedBody);
if (renamedToolNames?.size) {
toolNameMap = new Map([...(toolNameMap || []), ...renamedToolNames]);
}
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
} catch (error) {
trackPendingRequest(model, provider, connectionId, false, true);
@@ -508,7 +525,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Provider forced streaming but client wants JSON
if (!clientRequestedStreaming && providerRequiresStreaming) {
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog });
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, toolNameMap, trackDone, appendLog });
if (result) { streamController.handleComplete(); return result; }
}

View File

@@ -11,6 +11,7 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav
import { saveRequestDetail } from "@/lib/usageDb.js";
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js";
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
function parseToolArguments(value) {
@@ -415,7 +416,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
return {
success: true,
response: new Response(JSON.stringify(translatedResponse), {
response: new Response(JSON.stringify(restoreToolNames(translatedResponse, toolNameMap)), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
})
};

View File

@@ -1,5 +1,6 @@
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
@@ -215,17 +216,13 @@ export async function handleForcedSSEToJson({
clientRawRequest,
onRequestSuccess,
customToolNames,
toolNameMap,
trackDone,
appendLog,
reqTag,
log,
streamErrorPatterns,
}) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE =
contentType.includes("text/event-stream") ||
(contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
trackDone();
@@ -306,7 +303,7 @@ export async function handleForcedSSEToJson({
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
return {
success: true,
response: new Response(JSON.stringify(jsonResponse), {
response: new Response(JSON.stringify(restoreToolNames(jsonResponse, toolNameMap)), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
@@ -406,7 +403,7 @@ export async function handleForcedSSEToJson({
return {
success: true,
response: new Response(JSON.stringify(finalResp), {
response: new Response(JSON.stringify(restoreToolNames(finalResp, toolNameMap)), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
@@ -432,8 +429,16 @@ export async function handleForcedSSEToJson({
"Invalid SSE response for non-streaming request",
);
if (parsed.error) {
// Structured error chunks may carry the real upstream status (e.g. the
// Qoder executor emits status 403 for billing envelopes). Preserve it so
// the account loop locks/falls back on the right status instead of a
// generic 502. Anything outside 400-599 still maps to 502.
const upstreamStatus = Number(parsed.error.status);
const status = Number.isInteger(upstreamStatus) && upstreamStatus >= 400 && upstreamStatus <= 599
? upstreamStatus
: HTTP_STATUS.BAD_GATEWAY;
return createErrorResult(
HTTP_STATUS.BAD_GATEWAY,
status,
parsed.error.message || "Upstream SSE stream failed",
);
}
@@ -508,7 +513,7 @@ export async function handleForcedSSEToJson({
return {
success: true,
response: new Response(JSON.stringify(finalBody), {
response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",

View File

@@ -1,18 +1,93 @@
// HuggingFace Inference API — returns binary image
import { nowSec } from "./_base.js";
// HuggingFace Inference Providers router — returns binary image
//
// The router is a switchboard in front of many inference providers and is
// addressed as `<baseUrl>/<provider>/<providerModelId>`. `providerModelId` is
// the id the *provider* uses, which is not the Hub model id, so it is resolved
// through `imageConfig.modelMap` (built from the Hub API's
// inferenceProviderMapping and limited to providers the router forwards to).
//
// The legacy `api-inference.huggingface.co` host is gone (DNS ENOTFOUND) and is
// deliberately not referenced anywhere here.
import { nowSec, urlToBase64 } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = PROVIDER_MEDIA["huggingface"]?.imageConfig?.baseUrl;
const imageConfig = () => PROVIDER_MEDIA["huggingface"]?.imageConfig || {};
const BASE_URL = imageConfig().baseUrl;
const MODEL_MAP = imageConfig().modelMap || {};
// A plain-object lookup returns inherited truthy values for keys like "toString" or
// "constructor", which would build nonsense URLs. Resolve own keys only.
const lookup = (model) => (Object.hasOwn(MODEL_MAP, model) ? MODEL_MAP[model] : undefined);
// modelMap values are either a bare path (text-to-image) or { path, task }.
const mappingPath = (entry) => (typeof entry === "string" ? entry : entry.path);
const mappingTask = (entry) => (typeof entry === "string" ? "text-to-image" : entry.task || "text-to-image");
// A connection may point at its own endpoint (self-hosted Text Generation
// Inference / TGI container). That endpoint already knows its own model ids, so
// the router mapping does not apply and the Hub id is passed through verbatim.
function customBaseUrl(creds) {
const url = creds?.providerSpecificData?.baseUrl;
return typeof url === "string" && url.trim() ? url.trim().replace(/\/+$/, "") : null;
}
// The router's image-to-image payload wants raw base64 — not a data URL, not a URL.
// Accept every shape our own callers use (data URL, bare base64, remote URL, array).
async function sourceImage(body) {
const raw = body?.image || (Array.isArray(body?.images) ? body.images[0] : null);
if (typeof raw !== "string" || !raw.trim()) return null;
const value = raw.trim();
if (/^https?:\/\//i.test(value)) return await urlToBase64(value);
const match = /^data:image\/[^;]+;base64,(.+)$/i.exec(value);
return match ? match[1] : value;
}
export default {
buildUrl: (model) => `${BASE_URL}/${model}`,
buildUrl: (model, creds) => {
const override = customBaseUrl(creds);
if (override) {
// The model id is client-controlled; on a custom endpoint it lands in a URL
// path verbatim, so reject traversal/query injection (mirrors sttCore's guard).
if (model.includes("..") || model.includes("//") || /[?#]/.test(model)) {
throw new Error(`HuggingFace: invalid model ID "${model}"`);
}
return `${override}/${model}`;
}
const entry = lookup(model);
if (!entry) {
throw new Error(
`HuggingFace: no HuggingFace router mapping for model "${model}". ` +
`Add it to imageConfig.modelMap in open-sse/providers/registry/huggingface.js, ` +
`or set a custom base URL on the connection.`
);
}
return `${BASE_URL}/${mappingPath(entry)}`;
},
buildHeaders: (creds) => {
const headers = { "Content-Type": "application/json" };
const key = creds?.apiKey || creds?.accessToken;
if (key) headers["Authorization"] = `Bearer ${key}`;
return headers;
},
buildBody: (_model, body) => ({ inputs: body.prompt }),
buildBody: async (model, body) => {
const entry = lookup(model);
const task = mappingTask(entry || "");
if (task === "image-to-image") {
const image = await sourceImage(body);
if (!image) {
throw new Error(
`HuggingFace: model "${model}" requires a source image. ` +
`Send it as "image" (or "images") in the request body.`
);
}
// inputs carries the source image; the prompt moves under parameters.
return { inputs: image, parameters: { prompt: body.prompt } };
}
return { inputs: body.prompt };
},
// HF returns raw image bytes — convert to b64_json
async parseResponse(response) {
const buf = await response.arrayBuffer();

View File

@@ -0,0 +1,95 @@
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { PROVIDER_MEDIA } from "../providers/index.js";
import { generateSessionId } from "../executors/opencode-zen.js";
/**
* Core System One (Jev) handler — native decision payload pass-through.
* URL/headers come from the registry's systemoneConfig; body and JSON response
* are forwarded untouched (decision models have no chat translation layer).
*
* @returns {Promise<{ success: boolean, response: Response, usage?: object, status?: number, error?: string }>}
*/
export async function handleSystemoneCore({
body,
modelInfo,
credentials,
log,
onRequestSuccess,
}) {
const { provider, model } = modelInfo;
const cfg = PROVIDER_MEDIA[provider]?.systemoneConfig;
if (!cfg?.baseUrl) {
return createErrorResult(
HTTP_STATUS.BAD_REQUEST,
`Provider '${provider}' does not support System One.`
);
}
// Validate input at the trust boundary; question-level shape is upstream's job.
if (body.state === undefined || body.state === null) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: state");
}
if (!body.questions || typeof body.questions !== "object" || Array.isArray(body.questions)) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: questions");
}
// noAuth free lanes carry accessToken "public" from the credential stub.
const token = credentials?.apiKey || credentials?.accessToken;
const headers = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(cfg.headers || {}),
// Zen lanes expect the official client session header on every request.
"x-opencode-session": generateSessionId(),
};
const requestBody = { ...body, model };
log?.debug?.("SYSTEMONE", `${provider.toUpperCase()} | ${model}`);
let providerResponse;
try {
providerResponse = await fetch(cfg.baseUrl, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
...(typeof AbortSignal?.timeout === "function"
? { signal: AbortSignal.timeout(FETCH_CONNECT_TIMEOUT_MS) }
: {}),
});
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
log?.debug?.("SYSTEMONE", `Fetch error: ${errMsg}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
if (!providerResponse.ok) {
const { statusCode, message } = await parseUpstreamError(providerResponse);
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
log?.debug?.("SYSTEMONE", `Provider error: ${errMsg}`);
return createErrorResult(statusCode, errMsg);
}
let responseBody;
try {
responseBody = await providerResponse.json();
} catch {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`);
}
if (onRequestSuccess) await onRequestSuccess();
const usage = responseBody?.usage;
return {
success: true,
usage: usage
? { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 }
: null,
response: new Response(JSON.stringify(responseBody), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}

View File

@@ -112,6 +112,8 @@ export const MODEL_CAPABILITIES = {
"glm-5.3-flash": { vision: true, videoInput: true, pdf: true, reasoning: true, thinkingFormat: "zai", contextWindow: 1000000, maxOutput: 131072 },
"glm-4.6v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000, maxOutput: 32768 },
"glm-4.5v": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "zai", contextWindow: 64000, maxOutput: 16384 },
// GLM-5.2 has 1M context — pattern *glm-5* only gives 200k, so override here
"glm-5.2": { reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 131072 },
// DeepSeek's first V4 model with image input; text limits match V4-Flash.
"deepseek-v4-flash-vision-exp": { vision: true, reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 },
@@ -165,6 +167,12 @@ export const PROVIDER_CAPABILITIES = {
"deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
"deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
},
// glm-5.3-flash on OpenCode Go is served by a backend that rejects the z.ai
// `thinking` object (400: unknown field "thinking") and wants reasoning_effort.
// Overrides the global entry, whose z.ai shape is correct for z.ai itself.
"opencode-go": {
"glm-5.3-flash": { vision: true, videoInput: true, pdf: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 131072 },
},
"codex": {
"gpt-6-astra": { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 },
"gpt-5.6-sol": CODEX_GPT_56_SOL_CAPS,
@@ -205,6 +213,10 @@ export const PROVIDER_CAPABILITIES = {
"minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 128000 },
"kimi-k2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
"kimi-k2.6": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
"kimi-k2.5": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 164000, maxOutput: 32000 },
"hy3-preview": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 192000, maxOutput: 64000 },
"deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
"deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
// Per-model values mirror the server's product-config payload (the plugin
// fetches it from copilot.tencent.com; the `models[]` entries carry
// maxInputTokens/maxOutputTokens/supportsImages). contextWindow =
@@ -226,45 +238,6 @@ export const PROVIDER_CAPABILITIES = {
// contract). maxOutput 128000 per the server's product-config payload.
"deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 },
},
// CodeBuddy intl — same gateway catalog as CN, so deepseek-v4.1-flash mirrors
// the codebuddy-cn entry (the openai-style reasoning_effort format matters:
// the generic *deepseek-v4* pattern would otherwise pick the vendor-native
// "deepseek" thinking shape, which the CodeBuddy gateway does not accept).
"codebuddy-intl": {
"deepseek-v4.1-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: true, contextWindow: 1000000, maxOutput: 128000 },
},
// Qoder — upstream exposes opaque internal ids (dfmodel, kmodel, …); the
// registry `name` is display-only and capability lookup matches on the raw
// id, so every qoder model would fall through to DEFAULT_CAPABILITIES
// (200K) without this map. contextWindow follows the real model family's
// spec: the /algo/api/v2/model/list max_input_tokens under-reports some
// windows (GLM-5.3 / Kimi-K3 / Qwen3.8-Max claim 180K but accept more).
// max_output_tokens arrives as 0 for every model, so outputs are
// best-guess from the real model family. Vision tags below follow the
// upstream is_vl flag. The executor uploads inlined images to
// /api/v2/image/upload and leaves image_urls/chat_context.imageUrls null
// (same as qodercli). reasoning:true on all of them — every model can
// reason; the upstream is_reasoning flag only drives model_config selection.
// thinkingFormat keeps the true-model family for documentation/UI, but
// thinkingCanDisable:false everywhere: the executor only forwards
// messages/tools/max_tokens, and thinking is fixed upstream via
// modelConfig.is_reasoning — client thinking intent is dropped, so "none"
// must never be offered as an option.
"qoder": {
"ultimate": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Opus 5
"performance": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Sonnet 5
"dmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Pro
"dfmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Flash
"gmodel": { reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3
"gfmodel": { vision: true, reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3-Flash
"kmodel_latest": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Kimi-K3
"kmodel": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 65536 }, // Kimi-K2.7-Code
"mmodel": { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 512000 }, // MiniMax-M3
"qmodel_latest": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Max
"qmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Plus
"qfmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Flash
"qmodel_38max": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Max
},
// Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output).
"poolside": {
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
@@ -282,6 +255,10 @@ export const PROVIDER_CAPABILITIES = {
},
};
// Qoder CN serves the identical model catalog from the CN gateway, so it shares
// the intl Qoder capability table verbatim (vision/reasoning/contextWindow).
PROVIDER_CAPABILITIES["qoder-cn"] = PROVIDER_CAPABILITIES["qoder"];
/**
* Pattern fallback — glob (* = wildcard), matched case-insensitively and
* anchored (^...$) so a pattern must match the full model id. ORDER MATTERS:
@@ -351,7 +328,7 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*omni*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144, maxOutput: 65536 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
{ pattern: "*qwen*max*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen3.5*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen3.6*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen3.7*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
@@ -390,14 +367,16 @@ export const PATTERN_CAPABILITIES = [
// ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } },
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1000000, maxOutput: 131072 } },
{ pattern: "*minimax-m2.7*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax-m2.5*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } },
{ pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision + <think>-tag reasoning, always-on, can't disable) ──
{ pattern: "*mimo*v2.6*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 131072 } },
{ pattern: "*mimo*", caps: { vision: true, reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 131072 } },
// ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
{ pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
@@ -440,6 +419,52 @@ export const PATTERN_CAPABILITIES = [
// unknown models on these providers, trust vision instead of stripping images.
const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
/**
* Aggregate capabilities for a combo from its constituent model IDs.
* Each entry in comboModels is a fully-qualified "provider/model" string.
*
* Union: vision, pdf, audioInput, videoInput, imageOutput, audioOutput, search
* Intersection: tools
* Primary: reasoning fields from the first (primary) model
* Conservative: contextWindow = min; maxOutput = max
*
* @param {string[]} comboModels
* @param {Object|null} [comboLookup] optional map of combo name → models array for nested resolution
* @param {number} [_depth] internal recursion depth guard
* @returns {object|null} full capabilities object, or null for empty input
*/
export function aggregateComboCapabilities(comboModels, comboLookup = null, _depth = 0) {
if (!comboModels?.length || _depth > 6) return null;
const allCaps = comboModels.map((fullId) => {
// Nested combo: bare name (no slash) that exists in the lookup — recurse
if (!fullId.includes("/") && comboLookup?.[fullId]) {
return aggregateComboCapabilities(comboLookup[fullId], comboLookup, _depth + 1)
?? getCapabilitiesForModel(null, fullId);
}
const slash = fullId.indexOf("/");
const provider = slash === -1 ? null : fullId.slice(0, slash);
const model = slash === -1 ? fullId : fullId.slice(slash + 1);
return getCapabilitiesForModel(provider, model);
});
const first = allCaps[0];
return {
vision: allCaps.some((c) => c.vision),
pdf: allCaps.some((c) => c.pdf),
audioInput: allCaps.some((c) => c.audioInput),
videoInput: allCaps.some((c) => c.videoInput),
imageOutput: allCaps.some((c) => c.imageOutput),
audioOutput: allCaps.some((c) => c.audioOutput),
search: allCaps.some((c) => c.search),
tools: allCaps.every((c) => c.tools),
reasoning: first.reasoning,
thinkingFormat: first.thinkingFormat,
thinkingCanDisable: first.thinkingCanDisable,
thinkingRange: first.thinkingRange,
contextWindow: Math.min(...allCaps.map((c) => c.contextWindow)),
maxOutput: Math.max(...allCaps.map((c) => c.maxOutput)),
};
}
/**
* Resolve capabilities for a model using the 4-step fallback chain,
* merged over DEFAULT_CAPABILITIES so the result is always complete.

View File

@@ -23,7 +23,7 @@ function buildTransport(transport, oauth) {
const MEDIA_KEYS = new Set([
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
"imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
"searchViaChat", "searchConfig", "fetchConfig",
"searchViaChat", "searchConfig", "fetchConfig", "systemoneConfig",
"modelsFetcher", "mediaPriority", "hiddenKinds",
]);

View File

@@ -57,6 +57,7 @@ export default {
},
},
models: [
{ id: "claude-opus-5-5", name: "Claude Opus 5.5" },
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "claude-fable-5-1", name: "Claude Fable 5.1" },
{ id: "claude-fable-5", name: "Claude Fable 5" },

View File

@@ -46,7 +46,7 @@ export default {
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code" },
{ id: "moonshotai/Kimi-K2.7-Code-Highspeed", name: "Kimi K2.7 Code Highspeed" },
{ id: "moonshotai/Kimi-K2.7-Code-Highspeed", name: "Kimi K2.7 Code HighSpeed" },
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
{ id: "zai-org/GLM-5.2", name: "GLM 5.2" },
@@ -58,14 +58,14 @@ export default {
{ id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
{ id: "xiaomi/mimo-v2.5", name: "MiMo V2.5" },
{ id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max" },
{ id: "Qwen/Qwen3.7-Plus", name: "Qwen 3.7 Plus" },
{ id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview" },
{ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" },
{ id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max" },
{ id: "Qwen/Qwen3.7-Plus", name: "Qwen 3.7 Plus" },
{ id: "stepfun/Step-3.7-Flash", name: "Step 3.7 Flash" },
{ id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
{ id: "tencent/Hy3", name: "Tencent Hy3" },
{ id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B A55B" },
{ id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra" },
{ id: "thinkingmachines/inkling", name: "Inkling" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },

View File

@@ -15,6 +15,7 @@ export default {
website: "https://huggingface.co",
notice: {
apiKeyUrl: "https://huggingface.co/settings/tokens",
text: "Runs through the Inference Providers router. Image and speech models are billed by the provider selected per model.",
},
},
category: "apikey",
@@ -25,10 +26,79 @@ export default {
transport: null,
models: [
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", params: [], kind: "image" },
{ id: "black-forest-labs/FLUX.1-dev", name: "FLUX.1 Dev", params: [], kind: "image" },
{ id: "black-forest-labs/FLUX.1-Krea-dev", name: "FLUX.1 Krea", params: [], kind: "image" },
{ id: "black-forest-labs/FLUX.1-Kontext-dev", name: "FLUX.1 Kontext", params: [], kind: "image", capabilities: ["edit"] },
{ id: "black-forest-labs/FLUX.2-dev", name: "FLUX.2 Dev", params: [], kind: "image", capabilities: ["edit"] },
{ id: "black-forest-labs/FLUX.2-klein-9B", name: "FLUX.2 Klein 9B", params: [], kind: "image", capabilities: ["edit"] },
{ id: "black-forest-labs/FLUX.2-klein-4B", name: "FLUX.2 Klein 4B", params: [], kind: "image", capabilities: ["edit"] },
{ id: "black-forest-labs/FLUX.2-klein-base-9B", name: "FLUX.2 Klein Base 9B", params: [], kind: "image", capabilities: ["edit"] },
{ id: "black-forest-labs/FLUX.2-klein-base-4B", name: "FLUX.2 Klein Base 4B", params: [], kind: "image", capabilities: ["edit"] },
{ id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", params: [], kind: "image" },
{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", params: ["language"], kind: "stt" },
{ id: "openai/whisper-small", name: "Whisper Small (HF)", params: ["language"], kind: "stt" },
{ id: "stabilityai/stable-diffusion-3.5-large", name: "Stable Diffusion 3.5 Large", params: [], kind: "image" },
{ id: "stabilityai/stable-diffusion-3.5-large-turbo", name: "Stable Diffusion 3.5 Large Turbo", params: [], kind: "image" },
{ id: "Qwen/Qwen-Image", name: "Qwen Image", params: [], kind: "image" },
{ id: "Qwen/Qwen-Image-2512", name: "Qwen Image 2512", params: [], kind: "image" },
{ id: "Qwen/Qwen-Image-Edit", name: "Qwen Image Edit", params: [], kind: "image", capabilities: ["edit"] },
{ id: "Qwen/Qwen-Image-Edit-2509", name: "Qwen Image Edit 2509", params: [], kind: "image", capabilities: ["edit"] },
{ id: "Qwen/Qwen-Image-Edit-2511", name: "Qwen Image Edit 2511", params: [], kind: "image", capabilities: ["edit"] },
{ id: "ideogram-ai/ideogram-4-fp8", name: "Ideogram 4", params: [], kind: "image" },
{ id: "tencent/HunyuanImage-3.0", name: "HunyuanImage 3.0", params: [], kind: "image" },
{ id: "Tongyi-MAI/Z-Image-Turbo", name: "Z-Image Turbo", params: [], kind: "image" },
{ id: "krea/Krea-2-Turbo", name: "Krea 2 Turbo", params: [], kind: "image" },
{ id: "HiDream-ai/HiDream-I1-Fast", name: "HiDream I1 Fast", params: [], kind: "image" },
{ id: "playgroundai/playground-v2.5-1024px-aesthetic", name: "Playground v2.5", params: [], kind: "image" },
{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", params: [], kind: "stt" },
{ id: "openai/whisper-large-v3-turbo", name: "Whisper Large v3 Turbo (HF)", params: [], kind: "stt" },
],
serviceKinds: ["image", "stt"],
imageConfig: { baseUrl: "https://api-inference.huggingface.co/models" },
// Inference Providers router. The router is addressed as
// `<baseUrl>/<provider>/<providerModelId>` — see open-sse/handlers/imageProviders/huggingface.js.
// `modelMap` resolves a Hub model id to the provider-resolved id the router expects.
// A plain string value is the provider path. Image-to-image models use
// `{ path, task: "image-to-image" }`: their payload differs — the source image goes in
// `inputs` and the prompt under `parameters.prompt`. See
// https://huggingface.co/docs/inference-providers/tasks/image-to-image
// Only providers the router actually forwards to are listed: replicate, wavespeed and
// deepinfra appear in the Hub's inferenceProviderMapping but reject router traffic with
// "Model not supported by provider <name>".
imageConfig: {
baseUrl: "https://router.huggingface.co",
modelMap: {
"black-forest-labs/FLUX.1-schnell": "fal-ai/fal-ai/flux/schnell",
"black-forest-labs/FLUX.1-dev": "fal-ai/fal-ai/flux/dev",
"black-forest-labs/FLUX.1-Krea-dev": "fal-ai/fal-ai/flux/krea",
"black-forest-labs/FLUX.1-Kontext-dev": { path: "fal-ai/fal-ai/flux-kontext/dev", task: "image-to-image" },
"black-forest-labs/FLUX.2-dev": { path: "fal-ai/fal-ai/flux-2/edit", task: "image-to-image" },
"black-forest-labs/FLUX.2-klein-9B": { path: "fal-ai/fal-ai/flux-2/klein/9b/edit", task: "image-to-image" },
"black-forest-labs/FLUX.2-klein-4B": { path: "fal-ai/fal-ai/flux-2/klein/4b/distilled/edit", task: "image-to-image" },
"black-forest-labs/FLUX.2-klein-base-9B": { path: "fal-ai/fal-ai/flux-2/klein/9b/base/edit", task: "image-to-image" },
"black-forest-labs/FLUX.2-klein-base-4B": { path: "fal-ai/fal-ai/flux-2/klein/4b/base/edit", task: "image-to-image" },
"stabilityai/stable-diffusion-xl-base-1.0": "fal-ai/fal-ai/fast-sdxl",
"stabilityai/stable-diffusion-3.5-large": "fal-ai/fal-ai/stable-diffusion-v35-large",
"stabilityai/stable-diffusion-3.5-large-turbo": "fal-ai/fal-ai/stable-diffusion-v35-large/turbo",
"Qwen/Qwen-Image": "fal-ai/fal-ai/qwen-image",
"Qwen/Qwen-Image-2512": "fal-ai/fal-ai/qwen-image-2512",
"Qwen/Qwen-Image-Edit": { path: "fal-ai/fal-ai/qwen-image-edit", task: "image-to-image" },
"Qwen/Qwen-Image-Edit-2509": { path: "fal-ai/fal-ai/qwen-image-edit-2509", task: "image-to-image" },
"Qwen/Qwen-Image-Edit-2511": { path: "fal-ai/fal-ai/qwen-image-edit-plus", task: "image-to-image" },
"ideogram-ai/ideogram-4-fp8": "fal-ai/ideogram/v4",
"tencent/HunyuanImage-3.0": "fal-ai/fal-ai/hunyuan-image/v3/text-to-image",
"Tongyi-MAI/Z-Image-Turbo": "fal-ai/fal-ai/z-image/turbo",
"krea/Krea-2-Turbo": "fal-ai/fal-ai/krea-2/turbo",
"HiDream-ai/HiDream-I1-Fast": "fal-ai/fal-ai/hidream-i1-fast",
"playgroundai/playground-v2.5-1024px-aesthetic": "fal-ai/fal-ai/playground-v25",
},
},
// Speech-to-text goes through the hf-inference provider, which keeps the Hub
// model id as its provider-resolved id (`/hf-inference/models/<hubId>`).
// No `params` are declared: the router's ASR payload carries only `inputs` and
// `parameters.return_timestamps` / `parameters.generation_parameters` — it has no
// language field, so a UI-declared "language" would be silently dropped.
sttConfig: {
baseUrl: "https://router.huggingface.co/hf-inference/models",
authType: "apikey",
authHeader: "bearer",
format: "huggingface-asr",
},
};

View File

@@ -69,6 +69,7 @@ import p66 from "./ollama.js";
import p123 from "./ollama-search.js";
import p67 from "./openai.js";
import p68 from "./opencode-go.js";
import p68z from "./opencode-zen.js";
import p69 from "./opencode.js";
import p70 from "./openrouter.js";
import p71 from "./perplexity-web.js";
@@ -76,6 +77,7 @@ import p72 from "./perplexity.js";
import p73 from "./perplexity-agent.js";
import p74 from "./playht.js";
import p75 from "./qoder.js";
import p124 from "./qoder-cn.js";
import p77 from "./recraft.js";
import p78 from "./runwayml.js";
import p79 from "./sdwebui.js";
@@ -192,8 +194,10 @@ export default [
p65,
p66,
p123,
p124,
p67,
p68,
p68z,
p69,
p70,
p71,

View File

@@ -28,6 +28,7 @@ export default {
forceStream: true,
},
models: [
{ id: "gpt-5.5", name: "GPT-5.5" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },

View File

@@ -0,0 +1,135 @@
export default {
id: "opencode-zen",
priority: 205,
alias: "ocz",
aliases: [
"opencode-zen",
],
uiAlias: "ocz",
display: {
name: "OpenCode Zen",
icon: "terminal",
color: "#E87040",
textIcon: "OZ",
website: "https://opencode.ai/auth",
notice: {
text: "OpenCode Zen PAYG: pay-as-you-go, key from https://opencode.ai/auth. Same models as Zen: paid + free tiers on the fast lane.",
apiKeyUrl: "https://opencode.ai/auth",
},
},
category: "apikey",
transport: {
baseUrl: "https://opencode.ai/zen/v1/chat/completions",
headers: {},
usage: {
url: "https://opencode.ai/zen/v1/usage",
},
},
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
// translation. Mirrors opencode-go, pointed at /zen/v1 (see https://opencode.ai/docs/zen/).
transports: [
{ format: "openai", baseUrl: "https://opencode.ai/zen/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
{ format: "claude", baseUrl: "https://opencode.ai/zen/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
],
// supportedFormats follow the endpoint table in https://opencode.ai/docs/zen/
// (live /zen/v1/models, 2026-09-18: 71 ids).
models: [
// Claude (messages)
{ id: "claude-fable-5", name: "Claude Fable 5", supportedFormats: ["claude"] },
{ id: "claude-fable-5-1", name: "Claude Fable 5.1", supportedFormats: ["claude"] },
{ id: "claude-opus-5", name: "Claude Opus 5", supportedFormats: ["claude"] },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", supportedFormats: ["claude"] },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", supportedFormats: ["claude"] },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", supportedFormats: ["claude"] },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", supportedFormats: ["claude"] },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", supportedFormats: ["claude"] },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", supportedFormats: ["claude"] },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", supportedFormats: ["claude"] },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", supportedFormats: ["claude"] },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", supportedFormats: ["claude"] },
// Gemini (own path, via chat completions transport)
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash", supportedFormats: ["openai"] },
{ id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", supportedFormats: ["openai"] },
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", supportedFormats: ["openai"] },
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite", supportedFormats: ["openai"] },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", supportedFormats: ["openai"] },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", supportedFormats: ["openai"] },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", supportedFormats: ["openai"] },
// GPT / Grok / Muse Spark paid (responses)
{ id: "gpt-6-astra", name: "GPT 6 Astra", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", 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.5", name: "GPT 5.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.5-pro", name: "GPT 5.5 Pro", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.4-pro", name: "GPT 5.4 Pro", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.4-nano", name: "GPT 5.4 Nano", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.2", name: "GPT 5.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.1", name: "GPT 5.1", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.1-codex-max", name: "GPT 5.1 Codex Max", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.1-codex", name: "GPT 5.1 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5.1-codex-mini", name: "GPT 5.1 Codex Mini", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5", name: "GPT 5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5-codex", name: "GPT 5 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "gpt-5-nano", name: "GPT 5 Nano", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "grok-build-0.1", name: "Grok Build 0.1", 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: "muse-spark-1.3", name: "Muse Spark 1.3", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.2", name: "Muse Spark 1.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
// Qwen paid (messages)
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["claude"] },
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus", supportedFormats: ["claude"] },
// DeepSeek / GLM / MiniMax / Kimi / Big Pickle (chat completions)
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai"] },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai"] },
{ id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision Exp", 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.2", name: "GLM 5.2", supportedFormats: ["openai"] },
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
{ id: "glm-5", name: "GLM 5", supportedFormats: ["openai"] },
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai"] },
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai"] },
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai"] },
{ id: "kimi-k3", name: "Kimi K3", 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.5", name: "Kimi K2.5", supportedFormats: ["openai"] },
{ id: "big-pickle", name: "Big Pickle", supportedFormats: ["openai"] },
{ id: "union-alpha", name: "Union Alpha", supportedFormats: ["claude"] },
// Free tier on the keyed lane (chat completions)
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportedFormats: ["openai"] },
{ id: "mimo-v2.6-flash-free", name: "MiMo V2.6 Flash Free", supportedFormats: ["openai"] },
{ id: "mimo-v2.5-free", name: "MiMo V2.5 Free", supportedFormats: ["openai"] },
{ id: "ling-3.0-flash-fin-free", name: "Ling 3.0 Flash Fin Free", supportedFormats: ["openai"] },
{ id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", supportedFormats: ["openai"] },
{ id: "nemotron-3.5-lightning-free", name: "Nemotron 3.5 Lightning Free", supportedFormats: ["openai"] },
// Free tier on the keyed lane (responses)
{ id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Contributor Free", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
// System One (Jev) decision models on the native /systemone endpoint
{ id: "jev-1.13", name: "Jev 1.13", kind: "systemone" },
{ id: "jev-1.13-free", name: "Jev 1.13 Free", kind: "systemone" },
],
serviceKinds: ["llm", "systemone"],
systemoneConfig: {
baseUrl: "https://opencode.ai/zen/v1/systemone",
headers: {
"x-opencode-client": "desktop",
"User-Agent": "opencode/1.18.31",
},
},
modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" },
passthroughModels: true,
features: {
usage: true,
usageApikey: true,
},
};

View File

@@ -28,7 +28,16 @@ export default {
{ id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" },
{ id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Contributor Free", targetFormat: "openai-responses" },
{ id: "union-alpha", name: "Union Alpha Free", targetFormat: "claude" },
{ id: "jev-1.13-free", name: "Jev 1.13 Free", kind: "systemone" },
],
serviceKinds: ["llm", "systemone"],
systemoneConfig: {
baseUrl: "https://opencode.ai/zen/v1/systemone",
headers: {
"x-opencode-client": "desktop",
"User-Agent": "opencode/1.18.31",
},
},
modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" },
passthroughModels: true,
};

View File

@@ -43,8 +43,14 @@ export default {
{ id: "google/veo-3.1", name: "Veo 3.1 (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" },
{ id: "openai/sora-2-pro", name: "Sora 2 Pro (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" },
{ id: "bytedance/seedance-2.0", name: "Seedance 2.0 (via OpenRouter)", params: ["duration","aspect_ratio","resolution"], kind: "video" },
{ id: "typesafe/jev-1.13", name: "Jev 1.13", kind: "systemone" },
],
serviceKinds: ["llm","embedding","tts","imageToText","video"],
serviceKinds: ["llm","embedding","tts","imageToText","video","systemone"],
// System One decision API (TypeSafe-compatible): https://openrouter.ai/docs/guides/community/typesafe-sdk
systemoneConfig: {
baseUrl: "https://openrouter.ai/api/v1/systemone",
headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"},
},
ttsConfig: {
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
defaultModel: "openai/gpt-4o-mini-tts",

View File

@@ -0,0 +1,61 @@
export default {
id: "qoder-cn",
priority: 30,
alias: "qdcn",
uiAlias: "qdcn",
display: {
name: "Qoder CN",
icon: "water_drop",
color: "#EC4899",
website: "https://qoder.com.cn",
notice: {
signupUrl: "https://qoder.com.cn",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
authHint: "Personal Access Token (pt-...) from https://qoder.com.cn/account/integrations",
transport: {
baseUrl: "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation",
headers: {},
timeoutMs: 120000,
stallTimeoutMs: 120000,
usage: {
url: "https://openapi.qoder.com.cn/api/v2/quota/usage",
},
},
models: [
{ id: "ultimate", name: "Ultimate" },
{ id: "auto", name: "Auto" },
{ id: "performance", name: "Performance" },
{ id: "efficient", name: "Efficient" },
{ id: "lite", name: "Lite" },
{ id: "qmodel_38max", name: "Qwen3.8-Max" },
{ id: "qmodel_latest", name: "Qwen3.7-Max" },
{ id: "qmodel", name: "Qwen3.7-Plus" },
{ id: "qfmodel", name: "Qwen3.8-Flash" },
{ id: "kmodel_latest", name: "Kimi-K3" },
{ id: "kmodel", name: "Kimi-K2.7-Code" },
{ id: "gmodel", name: "GLM-5.3" },
{ id: "gfmodel", name: "GLM-5.3-Flash" },
{ id: "dmodel", name: "DeepSeek-V4-Pro" },
{ id: "dfmodel", name: "DeepSeek-V4-Flash" },
{ id: "mmodel", name: "MiniMax-M3" },
],
oauth: {
openApiBaseUrl: "https://openapi.qoder.com.cn",
centerBaseUrl: "https://gateway.qoder.com.cn",
chatBaseUrl: "https://gateway.qoder.com.cn",
deviceTokenUrl: "https://openapi.qoder.com.cn/api/v1/deviceToken/poll",
refreshUrl: "https://gateway.qoder.com.cn/algo/api/v3/user/refresh_token",
userInfoUrl: "https://openapi.qoder.com.cn/api/v1/userinfo",
quotaUsageUrl: "https://openapi.qoder.com.cn/api/v2/quota/usage",
loginUrl: "https://qoder.com.cn/device/selectAccounts",
},
features: {
usage: true,
// PAT (apikey) connections also carry quota usage (via job-token exchange).
usageApikey: true,
},
};

View File

@@ -2,9 +2,9 @@ import { CLAUDE_API_HEADERS } from "../shared.js";
// Dual auth (same pattern as kimi):
// - API key (sk-...) → cloud API on api.xiaomimimo.com
// - Desktop account/OAuth → same cloud host, plus the Desktop-exclusive Preview
// models served by the account-service route on mimo-server-cn.xiaomimimo.com
// (authorized by a Xiaomi account session cookie, not the key).
// - Desktop account/OAuth → same cloud host, plus the dual-route v2.6 models
// served by the account-service route (mimo-server-<cluster>.xiaomimimo.com),
// authorized by a Xiaomi account session cookie, not the key.
// Endpoint is picked per model in the executor, same as opencode-go's /responses split.
export default {
id: "xiaomi-mimo",
@@ -30,6 +30,16 @@ export default {
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
// Keys are cluster-specific. MiMo Desktop declares five regions
// (CN/SGP/AMS/RU/IN) — host + sid follow mimo-server-<code> / mimo<code>.
regions: [
{ id: "cn", label: "China (中国大陆)" },
{ id: "sgp", label: "Singapore (新加坡)" },
{ id: "ams", label: "Europe · Amsterdam (欧洲)" },
{ id: "ru", label: "Russia (俄罗斯)" },
{ id: "in", label: "India (印度)" },
],
defaultRegion: "sgp",
serviceKinds: ["llm", "tts"],
transport: {
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
@@ -50,10 +60,10 @@ export default {
},
],
models: [
// Desktop-exclusive — served by the account-service route, which only accepts
// OpenAI format, so supportedFormats pins them to the openai transport.
{ id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] },
{ id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", supportedFormats: ["openai"] },
// Cloud API & Desktop dual-route models (prefers the desktop account quota when available)
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", upstreamModelId: "xiaomi/mimo-v2.6-pro", supportedFormats: ["openai"] },
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", upstreamModelId: "xiaomi/mimo-v2.6-flash", supportedFormats: ["openai"] },
{ id: "mimo-v2.6-pro-ultraspeed", name: "MiMo V2.6 Pro UltraSpeed", upstreamModelId: "xiaomi/mimo-v2.6-pro-ultraspeed", supportedFormats: ["openai"] },
// Cloud API models (api.xiaomimimo.com/v1)
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
{ id: "mimo-v2.5", name: "MiMo V2.5" },

View File

@@ -36,6 +36,13 @@ import { DEFAULT_RETRY_CONFIG, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtim
* MediaConfig: { serviceKinds:[...], ttsConfig, sttConfig, embeddingConfig, imageConfig,
* searchViaChat:{defaultModel,pricingUrl}, hiddenKinds } — each *Config: {baseUrl,authType,authHeader,
* format,defaultModel,models:[{id,name,dimensions?}]}.
*
* imageConfig.modelMap (optional): maps a client-facing model id to a provider-resolved id when
* those differ — e.g. the HuggingFace Inference Providers router, where a Hub id like
* `black-forest-labs/FLUX.1-schnell` is addressed as `fal-ai/fal-ai/flux/schnell`. A value is
* either the provider path, or `{path, task}` when the request shape differs per task
* (HuggingFace uses task:"image-to-image" to move the prompt under `parameters.prompt`).
* Ignored by providers whose model ids are sent verbatim.
*/
// Shared transport defaults — provider only overrides fields that differ.

View File

@@ -22,7 +22,7 @@ export function mapStainlessArch() {
// Anthropic API version (single source — reused across claude-format providers/executors)
export const ANTHROPIC_API_VERSION = "2023-06-01";
export const CLAUDE_CLI_VERSION = "2.1.258";
export const CLAUDE_CLI_VERSION = "2.1.280";
// Shared Claude-compatible API headers (reused across claude-format providers)
export const CLAUDE_API_HEADERS = {

View File

@@ -41,6 +41,7 @@ const PATTERN_THINKING = [
{ provider: "codex", pattern: "*gpt-5.6-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
{ 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: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] },
// 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
// (disable thinking instead). none kept for the picker = disable.

View File

@@ -1,5 +1,5 @@
// RTK port: compress tool_result content in LLM request bodies
// Injected at the top of translateRequest (before any format translation)
// Applied in chatCore on the source-format body, before translateRequest.
import { RAW_CAP, MIN_COMPRESS_SIZE } from "./constants.js";
import { autoDetectFilter } from "./autodetect.js";
import { safeApply } from "./applyFilter.js";

View File

@@ -12,19 +12,20 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js";
const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
const HARD_CAPS = new Set(CAPABILITY_KEYS);
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free";
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.6-flash-free";
const upgradeLegacyModel = (m) => (m === "oc/mimo-v2.5-free" ? DEFAULT_FALLBACK_MODEL : m);
// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat:
// accept the legacy array form [{model, enabled}] (treated as enabled, fallback).
function normalizeCapEntry(entry) {
if (Array.isArray(entry)) {
return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) };
return { enabled: true, roundRobin: false, models: entry.map((e) => upgradeLegacyModel(e?.model || e)).filter(Boolean) };
}
if (entry && typeof entry === "object") {
return {
enabled: entry.enabled !== false,
roundRobin: !!entry.roundRobin,
models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [],
models: Array.isArray(entry.models) ? entry.models.map(upgradeLegacyModel).filter(Boolean) : [],
};
}
return { enabled: false, roundRobin: false, models: [] };

View File

@@ -13,9 +13,13 @@
*
* PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY
* requests directly, so we exchange it for a short-lived job token (jt-...)
* via openapi.qoder.sh/api/v1/jobToken/exchange (plain JSON POST), then use
* that job token for signing. Job-token traffic must hit api2.qoder.sh —
* api3 rejects jt- with "Login expired" (403).
* via the region's jobToken/exchange endpoint (plain JSON POST), then use
* that job token for signing. On intl, job-token traffic must hit api2.qoder.sh —
* api3 rejects jt- with "Login expired" (403); CN serves it from the same
* gateway host.
*
* The region (intl/cn) is derived from credentials.provider (or an explicit
* options.region override) so the same catalog logic works for both sites.
*/
import { createHash } from "crypto";
@@ -23,12 +27,12 @@ import { createHash } from "crypto";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { buildCosyHeaders } from "../shared/qoder/cosy.js";
import {
QODER_MODEL_LIST_URL,
QODER_CHAT_BASE_ALT,
QODER_JOB_TOKEN_EXCHANGE_URL,
QODER_USERINFO_URL,
QODER_IDE_VERSION,
QODER_CLIENT_TYPE,
qoderRegionOf,
qoderJobTokenExchangeUrl,
qoderUserInfoUrl,
qoderInferenceBase,
} from "../shared/qoder/constants.js";
const FETCH_TIMEOUT_MS = 15_000;
@@ -63,9 +67,9 @@ const inflight = new Map();
* Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
* This endpoint is plain JSON POST — NOT COSY-signed.
*/
async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
async function exchangeJobToken(pat, proxyOptions = null, signal = null, region = "intl") {
const res = await proxyAwareFetch(
QODER_JOB_TOKEN_EXCHANGE_URL,
qoderJobTokenExchangeUrl(region),
{
method: "POST",
headers: {
@@ -101,10 +105,10 @@ async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
* Resolve the Qoder userId for a job token (needed for COSY signing).
* Returns "" on any failure — callers fall back to the stored userId.
*/
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) {
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null, region = "intl") {
try {
const res = await proxyAwareFetch(
QODER_USERINFO_URL,
qoderUserInfoUrl(region),
{
method: "GET",
headers: {
@@ -125,16 +129,17 @@ async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = nu
}
/**
* Resolve a PAT to a job-token credential, cached per-PAT.
* Resolve a PAT to a job-token credential, cached per-PAT-per-region.
*/
async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
const cached = patJobCache.get(pat);
async function resolvePatCredential(pat, proxyOptions = null, signal = null, region = "intl") {
const cacheKey = `${region}:${pat}`;
const cached = patJobCache.get(cacheKey);
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached;
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal, region);
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal, region);
const resolved = { accessToken: jobToken, userId, expiresAt };
patJobCache.set(pat, resolved);
patJobCache.set(cacheKey, resolved);
return resolved;
}
@@ -142,11 +147,14 @@ async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
* Resolve connection credentials to COSY-signable form:
* - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId
* - everything else → passed through unchanged
*
* Region defaults to the one implied by credentials.provider (qoder-cn → cn).
*/
export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) {
export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null, region) {
const raw = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(raw)) {
const resolved = await resolvePatCredential(raw, proxyOptions, signal);
const effRegion = region || qoderRegionOf(credentials?.provider);
const resolved = await resolvePatCredential(raw, proxyOptions, signal, effRegion);
return {
...credentials,
accessToken: resolved.accessToken,
@@ -163,13 +171,14 @@ export async function resolveQoderCredentials(credentials, proxyOptions = null,
}
/**
* Stable cache key per credential (so different login sessions for the same
* account share an entry).
* Stable cache key per credential+region (so different login sessions for the
* same account share an entry, and the same PAT on both sites stays apart).
*/
function cacheKey(credentials) {
const psd = credentials?.providerSpecificData || {};
const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous";
return createHash("sha256").update(`qoder:${seed}`).digest("hex");
const region = qoderRegionOf(credentials?.provider);
return createHash("sha256").update(`qoder:${region}:${seed}`).digest("hex");
}
/**
@@ -192,15 +201,13 @@ function cosyCredsFromConnection(credentials) {
* rawConfigs: Map<modelKey, modelConfigObject> }
* or `null` on any error.
*/
async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) {
async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null, region = "intl") {
const creds = cosyCredsFromConnection(credentials);
if (!creds.userId || !creds.authToken) return null;
// Job-token traffic is rejected by api3 ("Login expired" 403) — the
// official qodercli serves it from api2 instead.
const modelListUrl = String(creds.authToken).startsWith("jt-")
? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list`
: QODER_MODEL_LIST_URL;
// Intl job-token traffic is rejected by api3 ("Login expired" 403) — the
// official qodercli serves it from api2 instead; CN uses the single gateway.
const modelListUrl = `${qoderInferenceBase(credentials, region)}/algo/api/v2/model/list`;
const headers = {
Accept: "application/json",
@@ -293,14 +300,20 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) {
* one upstream request per credential.
*/
export async function resolveQoderModels(credentials, options = {}) {
const region = options.region || qoderRegionOf(credentials?.provider);
let resolved;
try {
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal);
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal, region);
} catch (error) {
options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`);
return null;
}
if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) return null;
// Stamp the provider so cacheKey/catalog derive the region even when the
// caller's credentials object didn't carry a provider id (e.g. /v1/models).
if (resolved && !resolved.provider) {
resolved.provider = region === "cn" ? "qoder-cn" : "qoder";
}
const key = cacheKey(resolved);
const now = Date.now();
@@ -319,7 +332,7 @@ export async function resolveQoderModels(credentials, options = {}) {
}
const fetchPromise = (async () => {
const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions);
const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions, region);
if (!fetched) return null;
const entry = {
expiresAt: Date.now() + CACHE_TTL_MS,

View File

@@ -17,6 +17,7 @@ import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { getCommandCodeUsage } from "./usage/commandcode.js";
import { getOpenCodeGoUsage } from "./usage/opencode-go.js";
import { getOpenCodeZenUsage } from "./usage/opencode-zen.js";
import { getGroqUsage } from "./usage/groq.js";
import { getZedUsage } from "./usage/zed.js";
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js";
@@ -43,12 +44,8 @@ const USAGE_HANDLERS = {
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
qoder: async (c) => {
// PAT (pt-...) connections must be exchanged to a job token before the
// quota endpoint accepts them.
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
},
qoder: (c) => getQoderUsageFor(c),
"qoder-cn": (c) => getQoderUsageFor(c),
iflow: (c) => getIflowUsage(c.accessToken),
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
@@ -62,6 +59,7 @@ const USAGE_HANDLERS = {
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
"opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions),
"opencode-zen": (c) => getOpenCodeZenUsage(c.apiKey, c.proxyOptions),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions),
@@ -70,6 +68,14 @@ const USAGE_HANDLERS = {
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
};
// Qoder intl/CN share one usage path: PATs must be exchanged to a job token
// before the quota endpoint accepts them, and the quota URL comes from the
// provider's own registry usage block (region-correct via c.provider).
async function getQoderUsageFor(c) {
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions, c.provider || "qoder");
}
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
const providerDataWithProjectId = {

View File

@@ -25,10 +25,18 @@ export function _clearWeeklyCache() {
weeklyCache.clear();
}
// — Group-name to stable key mapping ——————————————————————
const GROUP_MATCHERS = [
{ pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" },
{ pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
// — Group-name and window to stable key mapping ——————————————————————
const GROUP_CONFIGS = [
{
pattern: /gemini/i,
weekly: { key: "gemini_weekly", displayName: "Gemini (Weekly)" },
session: { key: "gemini_session", displayName: "Gemini (5h)" },
},
{
pattern: /claude|gpt/i,
weekly: { key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
session: { key: "claude_gpt_session", displayName: "Claude & GPT (5h)" },
},
];
/**
@@ -60,32 +68,40 @@ export function parseWeeklyQuotaSummary(data) {
for (const bucket of buckets) {
if (!bucket || typeof bucket !== "object") continue;
// Identify weekly buckets by checking bucketId + displayName for "weekly"
const windowType = String(bucket.window || "").toLowerCase();
const bucketText = `${bucket.bucketId || ""} ${bucket.displayName || ""}`.toLowerCase();
if (!bucketText.includes("weekly")) continue;
const isWeekly = windowType === "weekly" || bucketText.includes("weekly");
const isSession = windowType === "5h" || bucketText.includes("five hour") || bucketText.includes("5h") || bucketText.includes("daily") || windowType === "daily";
// Skip disabled buckets
if (bucket.disabled === true) continue;
if (!isWeekly && !isSession) continue;
const remainingFraction = Number(bucket.remainingFraction);
// If a session (5h) bucket is marked disabled by upstream (because weekly was hit),
// keep it so the UI shows the 5h row, but with remainingFraction: 0.
// Disabled weekly buckets are truly disabled and skipped.
if (bucket.disabled === true && isWeekly) continue;
const remainingFraction = bucket.disabled === true ? 0 : Number(bucket.remainingFraction);
if (!Number.isFinite(remainingFraction)) continue;
// Match group to a known family
for (const matcher of GROUP_MATCHERS) {
if (matcher.pattern.test(displayName)) {
for (const config of GROUP_CONFIGS) {
if (config.pattern.test(displayName)) {
const target = isWeekly ? config.weekly : config.session;
if (result[target.key]) break; // first matching bucket per type wins
const total = 1000;
const remaining = Math.round(total * remainingFraction);
const used = Math.max(0, total - remaining);
result[matcher.key] = {
result[target.key] = {
used,
total,
resetAt: parseResetTime(bucket.resetTime),
remainingPercentage: remainingFraction * 100,
unlimited: false,
displayName: matcher.displayName,
displayName: target.displayName,
};
break; // first matching bucket per family wins
break;
}
}
}

View File

@@ -228,39 +228,37 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
proxyOptions
);
// Reconcile weekly quota against model family status:
// Reconcile short-window session quota if models are exhausted:
// If every model in a family is locked/exhausted (remainingPercentage === 0)
// until a future reset time, the weekly limit cannot be 100% available.
// On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports
// remainingFraction: 1 even after the starter quota is depleted and all models 429.
// until a future reset time, update the 5h session row (not the weekly row).
const entries = Object.entries(quotas);
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
if (weeklyQuotas.gemini_weekly && geminiModels.length > 0) {
if (weeklyQuotas.gemini_session && geminiModels.length > 0) {
const allGeminiExhausted = geminiModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0);
if (allGeminiExhausted && weeklyQuotas.gemini_weekly.remainingPercentage > 0) {
if (allGeminiExhausted && weeklyQuotas.gemini_session.remainingPercentage > 0) {
const maxResetAt = geminiModels.reduce((max, [, q]) =>
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
);
weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total;
weeklyQuotas.gemini_weekly.remainingPercentage = 0;
weeklyQuotas.gemini_session.used = weeklyQuotas.gemini_session.total;
weeklyQuotas.gemini_session.remainingPercentage = 0;
if (maxResetAt) {
weeklyQuotas.gemini_weekly.resetAt = maxResetAt;
weeklyQuotas.gemini_session.resetAt = maxResetAt;
}
}
}
if (weeklyQuotas.claude_gpt_weekly && claudeModels.length > 0) {
if (weeklyQuotas.claude_gpt_session && claudeModels.length > 0) {
const allClaudeExhausted = claudeModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0);
if (allClaudeExhausted && weeklyQuotas.claude_gpt_weekly.remainingPercentage > 0) {
if (allClaudeExhausted && weeklyQuotas.claude_gpt_session.remainingPercentage > 0) {
const maxResetAt = claudeModels.reduce((max, [, q]) =>
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
);
weeklyQuotas.claude_gpt_weekly.used = weeklyQuotas.claude_gpt_weekly.total;
weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0;
weeklyQuotas.claude_gpt_session.used = weeklyQuotas.claude_gpt_session.total;
weeklyQuotas.claude_gpt_session.remainingPercentage = 0;
if (maxResetAt) {
weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt;
weeklyQuotas.claude_gpt_session.resetAt = maxResetAt;
}
}
}

View File

@@ -24,11 +24,43 @@ export async function getIflowUsage(accessToken) {
}
}
const OLLAMA_LIMIT_WINDOWS = {
session: "Session (5h)",
weekly: "Weekly (7d)",
monthly: "Monthly",
};
function addUtcMonths(date, months) {
const total = date.getUTCMonth() + months;
const year = date.getUTCFullYear() + Math.floor(total / 12);
const month = ((total % 12) + 12) % 12;
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
return new Date(Date.UTC(
year, month, Math.min(date.getUTCDate(), lastDay),
date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(),
));
}
// Free plan: "usage resets monthly from the date you signed up" (ollama.com/pricing).
function nextMonthlyResetFromSignup(createdAt, now = new Date()) {
const anchor = new Date(createdAt);
if (Number.isNaN(anchor.getTime())) return null;
const elapsedMonths = (now.getUTCFullYear() - anchor.getUTCFullYear()) * 12
+ (now.getUTCMonth() - anchor.getUTCMonth());
for (let i = Math.max(0, elapsedMonths); i <= elapsedMonths + 1; i++) {
const candidate = addUtcMonths(anchor, i);
if (candidate > now) return candidate.toISOString();
}
return null;
}
/**
* Ollama Cloud Usage
* GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1
* ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed.
* POST https://ollama.com/api/me — plan label (fail-open).
* GET https://ollama.com/api/usage — `limits.<window>.usage` is a 0..1 ratio
* (1.0 = limit reached). Paid plans report session (5h) + weekly (7d); the
* free plan reports a single monthly window. No reset timestamp exposed;
* the free monthly reset is derived from the account's signup date.
* POST https://ollama.com/api/me — plan label + CreatedAt (fail-open).
* Auth: Authorization: Bearer <apiKey>
*/
export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) {
@@ -84,14 +116,20 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions
return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false };
}
const sessionRaw = limits.session?.usage;
const weeklyRaw = limits.weekly?.usage;
const sessionNum = Number(sessionRaw);
const weeklyNum = Number(weeklyRaw);
const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum);
const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum);
const monthlyResetAt = planRaw.toLowerCase() === "free" && me?.CreatedAt
? nextMonthlyResetFromSignup(me.CreatedAt)
: null;
if (!hasSession && !hasWeekly) {
const quotas = {};
for (const [key, label] of Object.entries(OLLAMA_LIMIT_WINDOWS)) {
const raw = limits[key]?.usage;
if (raw === undefined || raw === null) continue;
const ratio = Number(raw);
if (Number.isNaN(ratio)) continue;
quotas[label] = ratioQuota(ratio, key === "monthly" ? monthlyResetAt : null);
}
if (Object.keys(quotas).length === 0) {
return {
plan,
message: "Ollama Cloud connected. No usage limits reported.",
@@ -99,10 +137,6 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions
};
}
const quotas = {};
if (hasSession) quotas["Session (5h)"] = ratioQuota(sessionNum);
if (hasWeekly) quotas["Weekly (7d)"] = ratioQuota(weeklyNum);
return { plan, quotas };
} catch (error) {
return { message: `Ollama Cloud error: ${error.message}` };
@@ -193,13 +227,13 @@ export async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) {
}
}
export async function getQoderUsage(accessToken, proxyOptions = null) {
export async function getQoderUsage(accessToken, proxyOptions = null, providerId = "qoder") {
if (!accessToken) {
return { message: "Qoder usage unavailable: no access token" };
}
try {
const response = await proxyAwareFetch(
U("qoder").url,
U(providerId).url,
{
method: "GET",
headers: {

View File

@@ -0,0 +1,107 @@
/**
* OpenCode Zen usage — GET https://opencode.ai/zen/v1/usage
* Auth: Bearer <apiKey>
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { parseResetTime, toFiniteNumber, U } from "./shared.js";
const USAGE_URL = U("opencode-zen").url;
const QUOTA_NAMES = {
rolling: "Rolling",
weekly: "Weekly",
monthly: "Monthly",
};
function parsePercent(value) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
export async function getOpenCodeZenUsage(apiKey = null, proxyOptions = null) {
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
return {
message: "OpenCode Zen API key not available. Add a key to view usage.",
};
}
try {
const response = await proxyAwareFetch(
USAGE_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
Accept: "application/json",
},
},
proxyOptions,
);
if (response.status === 401) {
return {
plan: "OpenCode Zen",
message: "OpenCode Zen authentication failed. Check the API key.",
};
}
if (response.status === 403) {
const error = await response.json().catch(() => null);
const subscriptionRequired = error?.error?.type === "EntitlementError";
return {
plan: "OpenCode Zen",
message: subscriptionRequired
? "OpenCode Zen billing required for this API key."
: "OpenCode Zen access forbidden for this API key.",
};
}
if (!response.ok) {
return {
plan: "OpenCode Zen",
message: `OpenCode Zen usage API error (${response.status}).`,
};
}
const data = await response.json().catch(() => null);
if (!data?.usage || typeof data.usage !== "object") {
return {
plan: "OpenCode Zen",
message: "OpenCode Zen usage response did not contain quota data.",
};
}
const quotas = {};
for (const [period, name] of Object.entries(QUOTA_NAMES)) {
const quota = data.usage[period];
if (!quota || typeof quota !== "object") continue;
const percent = parsePercent(quota.percent);
if (percent === null) continue;
const used = Math.max(0, Math.min(100, toFiniteNumber(percent, 0)));
quotas[name] = {
used,
total: 100,
remaining: 100 - used,
remainingPercentage: 100 - used,
resetAt: parseResetTime(quota.resetsAt),
unlimited: false,
};
}
if (Object.keys(quotas).length === 0) {
return {
plan: "OpenCode Zen",
message: "OpenCode Zen usage response did not contain valid quota data.",
};
}
return { plan: "OpenCode Zen", quotas };
} catch (error) {
return { message: `OpenCode Zen error: ${error.message}` };
}
}

View File

@@ -8,20 +8,42 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
* Xiaomi MiMo account-session helpers (used for weekly quota).
*
* The weekly quota endpoint lives on the account service domain and is authorized
* by an account session cookie, NOT the sk- API key. Acquiring that cookie mirrors
* MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via
* the passportapi SSO, then authorized for the `mimopc` service, and finally stamped
* by the mimo-server /api/sts callback into a `serviceToken` cookie.
* by an account session cookie, NOT the sk- API key. Acquiring that cookie is a
* 1:1 port of MiMo Desktop's ServiceTokenManager (app.asar) — the GOLD STANDARD:
*
* Flow (verified against MiMo Desktop traffic):
* 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc)
* 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity
* 3. GET {location}&clientSign=... -> account-level serviceToken
* 4. GET account /pass/serviceLogin?sid=mimopc&callback=<sts>&_json=true
* 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope)
* getServiceToken(sid) / refreshServiceToken(sid):
* PHASE 1: GET https://account.xiaomi.com/pass/serviceLogin
* ?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
* Cookie: {userId, passToken, cUserId}
* -> {code, location, ssecurity, nonce, bSecondValidation, notificationUrl}
* -> code !== 0 is an error (never silent)
* PHASE 2: GET {location}&clientSign=sha1(nonce & ssecurity), follow the
* redirect chain absorbing Set-Cookie -> serviceToken
*
* sid is per-cluster (SID_BY_REGION): CN = mimopc, SGP = mimosgp.
*/
const API_BASE = "https://mimo-server-cn.xiaomimimo.com";
// Account-service cluster hosts. MiMo Desktop declares five regions
// (rn = {CN, SGP, RU, IN, EU}); the EU cluster is deployed in Amsterdam.
// Host + sid naming is unified: mimo-server-<code> / sid = mimo<code>
// (ams is the only non-country code). Verified live via /api/user/xiaomi/me.
const API_BASE_BY_REGION = {
cn: "https://mimo-server-cn.xiaomimimo.com",
sgp: "https://mimo-server-sgp.xiaomimimo.com",
ams: "https://mimo-server-ams.xiaomimimo.com",
ru: "https://mimo-server-ru.xiaomimimo.com",
in: "https://mimo-server-in.xiaomimimo.com",
};
const DEFAULT_API_BASE = API_BASE_BY_REGION.sgp;
// Cluster service sid — 1:1 with the host code: mimo<code>.
// Unknown/absent region falls back to SGP (the international/open cluster).
const SID_BY_REGION = { cn: "mimopc", sgp: "mimosgp", ams: "mimoams", ru: "mimoru", in: "mimoin" };
function sidForRegion(region) {
const r = String(region || "").toLowerCase();
return SID_BY_REGION[r] || SID_BY_REGION.sgp;
}
const API_BASE = DEFAULT_API_BASE;
const ACCOUNT_HOST = "account.xiaomi.com";
const API_UA =
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
@@ -112,65 +134,106 @@ function cookieHeader(jar) {
.join("; ");
}
/**
* Resolve the account-service base URL for a connection.
* @param {object|null} providerSpecificData - may carry `region` ("cn"|"sgp"|"ams"|"ru"|"in")
*/
export function resolveMimoServerBase(providerSpecificData = null) {
const region = String(providerSpecificData?.region || "").toLowerCase();
return API_BASE_BY_REGION[region] || DEFAULT_API_BASE;
}
/**
* Exchange a passToken for a mimo-server service session cookie.
* Primary path mirrors the Desktop ServiceTokenManager (app.asar):
* PHASE 1: GET /pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
* Cookie {userId,passToken,cUserId} -> {code,location,ssecurity,nonce}
* PHASE 2: GET {location}&clientSign=sha1(nonce&ssecurity), follow the chain
* (manual, absorbing Set-Cookie) -> serviceToken
* sid is per-cluster (SID_BY_REGION): cn=mimopc, sgp=mimosgp, ams=mimoams, ru=mimoru, in=mimoin.
* @returns {Promise<string|null>} Cookie header value, or null on failure.
*/
async function acquireServiceCookie(passJar, proxyOptions) {
async function acquireServiceCookie(passJar, proxyOptions, apiBase = DEFAULT_API_BASE, region = "sgp") {
const r = String(region || "").toLowerCase();
// Hard constraint: CN is ALWAYS direct (ignores proxy even if set)
const effectiveProxy = r === "cn" ? null : proxyOptions;
const sid = sidForRegion(r);
const viaDesktop = await acquireViaDesktopPhases(passJar, effectiveProxy, apiBase, sid);
if (viaDesktop) console.log(`[mimoAccount] desktop 2-phase OK (sid=${sid})`);
return viaDesktop;
}
async function acquireViaDesktopPhases(passJar, proxyOptions, apiBase, sid) {
const failLog = (reason) => console.log(`[mimoAccount] desktopPhase fail: ${reason}`);
const jar = { ...passJar };
const ck = () => cookieHeader(jar);
// 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc)
const r1 = await proxyAwareFetch(
`${API_BASE}/api/user/xiaomi/me`,
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
// PHASE 1 — single serviceLogin call with the TARGET sid (no passportapi
// prelude; ssecurity/nonce come straight from this response).
// Desktop only sends: userId, passToken, cUserId (no extra cookies)
const p1Jar = {};
if (jar.userId) p1Jar.userId = jar.userId;
if (jar.passToken) p1Jar.passToken = jar.passToken;
if (jar.cUserId) p1Jar.cUserId = jar.cUserId;
const p1Url = `https://${ACCOUNT_HOST}/pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=${encodeURIComponent(sid)}&_json=true`;
const p1 = await proxyAwareFetch(
p1Url,
{ headers: { Cookie: cookieHeader(p1Jar), "User-Agent": SSO_UA, Accept: "application/json" } },
proxyOptions,
);
const redirect = r1.headers.get("location");
if (!redirect) return null;
const stsCallback = new URL(redirect).searchParams.get("callback");
if (!stsCallback) return null;
const raw = await p1.text();
const clean = raw.replace(/^&&&START&&&/, "");
// Nonce > 2^53 loses precision in JSON.parse — extract raw literal for signing
const rawNonce = clean.match(/"nonce"\s*:\s*(\d+)/)?.[1];
let j = null;
try { j = JSON.parse(clean); } catch { /* handled below */ }
if (rawNonce && j) j.nonce = rawNonce;
// 2. passportapi SSO phase 1 -> nonce + ssecurity
const sso1 = await proxyAwareFetch(
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`,
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
proxyOptions,
);
const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, ""));
const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null);
if (!nonce || !j1.location) return null;
if (!j || typeof j.code !== "number" || j.code !== 0 || !j.location || !j.nonce || !j.ssecurity) {
failLog(
`phase1 sid=${sid} http=${p1.status} code=${j?.code ?? "?"} hasLoc=${!!j?.location}`
+ ` secondValidation=${j?.bSecondValidation ?? "?"} notificationUrl=${j?.notificationUrl ? "present" : "no"}`
+ ` body=${JSON.stringify(raw.slice(0, 200))}`,
);
return null;
}
absorbSetCookie(jar, p1);
// 3. passportapi SSO phase 2 -> account-level serviceToken
const sso2 = await proxyAwareFetch(
`${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`,
{ redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } },
proxyOptions,
);
absorbSetCookie(jar, sso2);
// PHASE 2 — clientSign the redirect, follow the redirect chain server-side.
// ⚠️ CRITICAL DESKTOP SPEC (app.asar / SSO_curl.cpp line 728: cookies.clear()):
// Phase 2 MUST NOT send ANY Cookie header! The server returns 200 OK with Set-Cookie: serviceToken!
const sep = j.location.includes("?") ? "&" : "?";
let current = `${j.location}${sep}clientSign=${signatureClientSign(rawNonce || j.nonce, j.ssecurity)}`;
// 4. mimopc SSO -> sts callback carrying a ticket
const sso3 = await proxyAwareFetch(
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`,
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
proxyOptions,
);
const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, ""));
absorbSetCookie(jar, sso3);
if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null;
for (let hop = 0; hop < 8; hop++) {
const res = await proxyAwareFetch(
current,
{ redirect: "manual", headers: { "User-Agent": SSO_UA } },
proxyOptions,
);
absorbSetCookie(jar, res);
const loc = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && loc) {
current = new URL(loc, current).toString();
continue;
}
break;
}
// 5. sts callback -> Set-Cookie: serviceToken (mimopc scope)
const sts = await proxyAwareFetch(
j3.location,
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
proxyOptions,
);
absorbSetCookie(jar, sts);
const sidKey = `${sid}_serviceToken`;
if (!jar.serviceToken && jar[sidKey]) {
jar.serviceToken = jar[sidKey];
}
const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"];
if (!jar.serviceToken) return null;
if (!jar.serviceToken) {
failLog(`phase2 no serviceToken sid=${sid} jar=[${Object.keys(jar).join(",")}]`);
return null;
}
const out = {};
for (const k of needed) if (jar[k]) out[k] = jar[k];
for (const [k, v] of Object.entries(jar)) {
if (!v) continue;
if (k === "serviceToken" || k === "userId" || /_(ph|slh)$/.test(k)) out[k] = v;
}
return cookieHeader(out);
}
@@ -179,13 +242,15 @@ async function acquireServiceCookie(passJar, proxyOptions) {
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
*/
async function getServiceCookie(providerSpecificData, proxyOptions) {
const apiBase = resolveMimoServerBase(providerSpecificData);
const passJar = providerSpecificData?.mimoPassToken
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
: await readDesktopAccountCookies();
if (!passJar) return { cookie: null, reason: "no-pass-token" };
// One cached session per passToken — accounts/connections rotate independently.
const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex");
// One cached session per passToken+cluster — accounts/connections rotate
// independently, and the same passToken maps to different sessions per region.
const key = crypto.createHash("sha256").update(`${apiBase}|${passJar.passToken}`).digest("hex");
const cached = _cache.get(key);
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
@@ -202,8 +267,9 @@ async function getServiceCookie(providerSpecificData, proxyOptions) {
const promise = (async () => {
try {
return await acquireServiceCookie(passJar, proxyOptions);
} catch {
return await acquireServiceCookie(passJar, proxyOptions, apiBase, providerSpecificData?.region);
} catch (e) {
console.log(`[mimoAccount] acquire threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
return null; // network/parse failure — callers degrade, never throw
} finally {
_inflight.delete(key);
@@ -234,7 +300,8 @@ export async function getMimoAccountCookie(providerSpecificData = null, proxyOpt
try {
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
return cookie;
} catch {
} catch (e) {
console.log(`[mimoAccount] getMimoAccountCookie threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
return null;
}
}
@@ -250,7 +317,7 @@ export async function getMimoAccountUsage(providerSpecificData = null, proxyOpti
}
try {
const res = await proxyAwareFetch(
`${API_BASE}/api/user/usage`,
`${resolveMimoServerBase(providerSpecificData)}/api/user/usage`,
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
proxyOptions,
);

View File

@@ -1,38 +1,106 @@
/**
* Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch.
*
* Endpoint set:
* openapi.qoder.sh - device flow + userinfo + quota usage
* center.qoder.sh - token refresh (best-effort, currently 403 for device tokens)
* api3.qoder.sh - inference (chat) + model list, requires COSY signing
* qoder.com/device - browser landing page for device authorization
* Qoder runs two regional sites with parallel endpoint shapes:
* intl (qoder) CN (qoder-cn)
* openapi.qoder.sh openapi.qoder.com.cn - device flow + userinfo + quota usage
* center.qoder.sh gateway.qoder.com.cn - token refresh (best-effort, 403 for device tokens)
* api3.qoder.sh gateway.qoder.com.cn - inference (chat) + model list, requires COSY signing
* qoder.com/device qoder.com.cn/device - browser landing page for device authorization
*
* All path suffixes are identical between regions — only the hosts differ.
* Region-aware consumers call qoder*Url(region) / qoderInferenceBase(creds, region)
* and derive the region from the provider id via qoderRegionOf(). The named
* QODER_* constants below keep the intl defaults for backward compatibility.
*/
export const QODER_OPENAPI_BASE = "https://openapi.qoder.sh";
export const QODER_CENTER_BASE = "https://center.qoder.sh";
export const QODER_CHAT_BASE = "https://api3.qoder.sh";
export const QODER_REGION_INTL = "intl";
export const QODER_REGION_CN = "cn";
// Per-region base URLs. CN serves job tokens (jt-...) from the same gateway
// host — there is no api2-style split like intl's api2.qoder.sh.
const QODER_REGION_BASES = {
[QODER_REGION_INTL]: {
chat: "https://api3.qoder.sh",
chatAlt: "https://api2.qoder.sh",
openApi: "https://openapi.qoder.sh",
center: "https://center.qoder.sh",
login: "https://qoder.com/device/selectAccounts",
website: "https://qoder.com",
},
[QODER_REGION_CN]: {
chat: "https://gateway.qoder.com.cn",
chatAlt: "https://gateway.qoder.com.cn",
openApi: "https://openapi.qoder.com.cn",
center: "https://gateway.qoder.com.cn",
login: "https://qoder.com.cn/device/selectAccounts",
website: "https://qoder.com.cn",
},
};
/** Base URL set for a region; unknown regions fall back to intl. */
export function qoderRegionBases(region) {
return QODER_REGION_BASES[region] || QODER_REGION_BASES[QODER_REGION_INTL];
}
/** Region for a provider id — "cn" for qoder-cn, "intl" otherwise. */
export function qoderRegionOf(providerId) {
return providerId === "qoder-cn" ? QODER_REGION_CN : QODER_REGION_INTL;
}
export const QODER_OPENAPI_BASE = QODER_REGION_BASES[QODER_REGION_INTL].openApi;
export const QODER_CENTER_BASE = QODER_REGION_BASES[QODER_REGION_INTL].center;
export const QODER_CHAT_BASE = QODER_REGION_BASES[QODER_REGION_INTL].chat;
// Job-token (jt-...) traffic is rejected by api3 with "Login expired" (403);
// the official qodercli serves it from api2 instead.
export const QODER_CHAT_BASE_ALT = "https://api2.qoder.sh";
// the official qodercli serves it from api2 instead (intl only).
export const QODER_CHAT_BASE_ALT = QODER_REGION_BASES[QODER_REGION_INTL].chatAlt;
export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts";
export const QODER_LOGIN_URL = QODER_REGION_BASES[QODER_REGION_INTL].login;
// Device flow endpoints
export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`;
export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`;
export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`;
// Device flow endpoints (region-aware variants; these are the intl defaults)
export function qoderOpenApiBase(region) {
return qoderRegionBases(region).openApi;
}
export function qoderDeviceTokenUrl(region) {
return `${qoderOpenApiBase(region)}/api/v1/deviceToken/poll`;
}
export function qoderUserInfoUrl(region) {
return `${qoderOpenApiBase(region)}/api/v1/userinfo`;
}
export function qoderQuotaUsageUrl(region) {
return `${qoderOpenApiBase(region)}/api/v2/quota/usage`;
}
export function qoderRefreshTokenUrl(region) {
return `${qoderRegionBases(region).center}/algo/api/v3/user/refresh_token`;
}
export function qoderLoginUrl(region) {
return qoderRegionBases(region).login;
}
export function qoderWebsiteUrl(region) {
return qoderRegionBases(region).website;
}
export const QODER_DEVICE_TOKEN_URL = qoderDeviceTokenUrl(QODER_REGION_INTL);
export const QODER_USERINFO_URL = qoderUserInfoUrl(QODER_REGION_INTL);
export const QODER_QUOTA_USAGE_URL = qoderQuotaUsageUrl(QODER_REGION_INTL);
export const QODER_REFRESH_TOKEN_URL = qoderRefreshTokenUrl(QODER_REGION_INTL);
// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange.
// PATs cannot sign COSY requests directly — they must be exchanged first.
// This endpoint is NOT COSY-signed (plain JSON POST).
export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`;
export function qoderJobTokenExchangeUrl(region) {
return `${qoderOpenApiBase(region)}/api/v1/jobToken/exchange`;
}
export const QODER_JOB_TOKEN_EXCHANGE_URL = qoderJobTokenExchangeUrl(QODER_REGION_INTL);
// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed)
// Inference endpoints (under /algo on the chat host, all COSY-signed)
export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation";
export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`;
export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`;
export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`;
export function qoderModelListUrl(region) {
return `${qoderRegionBases(region).chat}/algo/api/v2/model/list`;
}
export const QODER_MODEL_LIST_URL = qoderModelListUrl(QODER_REGION_INTL);
// Official qodercli uploads images here (COSY-signed PUT multipart, field "file")
// instead of inlining base64 into agent_chat_generation.
export const QODER_IMAGE_UPLOAD_SIG_PATH = "/api/v2/image/upload";
@@ -55,7 +123,9 @@ export const QODER_CONTEXT_TIER_MODES = Object.freeze({ AUTO: "auto", MAX: "max"
* "Login expired" (403). Device tokens (dt-...) stay on api3. PATs (pt-...)
* are exchanged for jt- before this is consulted.
*/
export function qoderInferenceBase(credentials) {
export function qoderInferenceBase(credentials, region = QODER_REGION_INTL) {
// CN serves every token kind from the single gateway host.
if (region === QODER_REGION_CN) return QODER_REGION_BASES[QODER_REGION_CN].chat;
const raw = credentials?.apiKey || credentials?.accessToken;
if (
typeof raw === "string" &&

View File

@@ -11,6 +11,10 @@ export function toOpenAIFinish(reason, format) {
case CLAUDE_STOP.MAX_TOKENS: return OPENAI_FINISH.LENGTH;
case CLAUDE_STOP.TOOL_USE: return OPENAI_FINISH.TOOL_CALLS;
case CLAUDE_STOP.STOP_SEQUENCE: return OPENAI_FINISH.STOP;
// A refusal is a blocked turn, not a clean stop: with the default mapping an
// OpenAI client saw finish_reason "stop" and an empty message (9Router logged
// "succeeded", OUT 0) and could not tell it from a real answer.
case CLAUDE_STOP.REFUSAL: return OPENAI_FINISH.CONTENT_FILTER;
default: return OPENAI_FINISH.STOP;
}
case "commandcode":
@@ -55,6 +59,7 @@ export function fromOpenAIFinish(reason, format) {
case OPENAI_FINISH.STOP: return CLAUDE_STOP.END_TURN;
case OPENAI_FINISH.LENGTH: return CLAUDE_STOP.MAX_TOKENS;
case OPENAI_FINISH.TOOL_CALLS: return CLAUDE_STOP.TOOL_USE;
case OPENAI_FINISH.CONTENT_FILTER: return CLAUDE_STOP.REFUSAL;
default: return CLAUDE_STOP.END_TURN;
}
default:

View File

@@ -14,9 +14,6 @@ const STRIP_RULES = [
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
{ provider: "cloudflare-ai", flattenContent: true },
// MiMo Desktop Preview models (account-service route): content must be plain string,
// rejects OpenAI content-part array. Cloud models keep their parts (mimo-v2-omni is multi-modal).
{ provider: "xiaomi-mimo", match: /preview/i, flattenContent: true },
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),
@@ -24,6 +21,15 @@ const STRIP_RULES = [
// "integer above maximum value, expected <= 32768". Pin an explicit endpoint cap;
// min() with the model ceiling still applies if a variant's own limit is lower.
{ provider: "volcengine-ark", match: /kimi/i, maxOutputCap: 32768, clampToModelMaxOutput: true },
// Strict OpenAI-compatible validators reject unknown assistant-message fields.
// Clients that talk to reasoning models (e.g. Hermes) echo the prior turn's
// reasoning back on every assistant message; Groq answers 400 and Mistral 422
// ("extra_forbidden") on it, which knocks these providers out of every
// multi-turn combo. Providers that *require* the field (DeepSeek, Kimi) are
// handled by reasoningContentInjector and are not listed here.
{ provider: "groq", dropMessageFields: ["reasoning_content", "reasoning", "reasoning_details"] },
{ provider: "mistral", dropMessageFields: ["reasoning_content", "reasoning", "reasoning_details"] },
{ provider: "cerebras", dropMessageFields: ["reasoning_content", "reasoning", "reasoning_details"] },
];
// Test a rule's match (regex or predicate) against the model id.
@@ -47,6 +53,15 @@ export function stripUnsupportedParams(provider, model, body) {
for (const key of rule.drop || []) {
if (body[key] !== undefined) delete body[key];
}
// Per-message field drop (assistant turns only — that is where clients replay reasoning).
if (Array.isArray(rule.dropMessageFields) && Array.isArray(body.messages)) {
for (const msg of body.messages) {
if (!msg || msg.role !== "assistant") continue;
for (const key of rule.dropMessageFields) {
if (msg[key] !== undefined) delete msg[key];
}
}
}
// CF Workers AI oneOf root schema only accepts content as plain string (#1926)
if (rule.flattenContent && Array.isArray(body.messages)) {
for (const msg of body.messages) {

View File

@@ -34,6 +34,8 @@ export function effortToThinkingLevel(effort) {
// Numeric budget → nearest discrete level (reverse map via thresholds).
// Returns null when budget <= 0 (no reasoning).
// Thresholds are midpoints between LEVEL_TO_BUDGET values: max (128000) is
// reachable, with the xhigh/max boundary at the 32768/128000 midpoint (80384).
export function budgetToLevel(budget) {
const b = Number(budget);
if (!b || b <= 0) return null;
@@ -41,7 +43,8 @@ export function budgetToLevel(budget) {
if (b <= 4096) return "low";
if (b <= 16384) return "medium";
if (b <= 28672) return "high";
return "xhigh";
if (b <= 80384) return "xhigh";
return "max";
}
// Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map).

View File

@@ -2,6 +2,7 @@ import { FORMATS } from "./formats.js";
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
import { prepareClaudeRequest } from "./formats/claude.js";
import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
import { restoreToolNames } from "../utils/opencodeFingerprint.js";
import { filterToOpenAIFormat } from "./formats/openai.js";
import { normalizeThinkingConfig } from "../services/provider.js";
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js";
@@ -166,7 +167,7 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
// even when no format conversion is needed, so streamed tool_use blocks must
// be decloaked here or the client sees an unknown ("_ide"-suffixed) tool.
if (sourceFormat === targetFormat) {
return [decloakStreamChunk(chunk, state?.toolNameMap)];
return [restoreToolNames(decloakStreamChunk(chunk, state?.toolNameMap), state?.toolNameMap)];
}
let results = [chunk];
@@ -179,7 +180,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`);
if (directFn) {
const converted = directFn(chunk, state);
return converted ? (Array.isArray(converted) ? converted : [converted]) : [];
const directResults = converted ? (Array.isArray(converted) ? converted : [converted]) : [];
return restoreToolNames(directResults, state?.toolNameMap);
}
// Step 1: target -> openai (if target is not openai)
@@ -210,6 +212,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
}
}
results = restoreToolNames(results, state?.toolNameMap);
// Attach OpenAI intermediate results for logging
if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
results._openaiIntermediate = openaiResults;

View File

@@ -279,10 +279,11 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
}
};
// Antigravity specific fields
if (isAntigravity) {
envelope.requestType = "agent";
} else {
// Antigravity specific fields.
// NOTE: the official Antigravity client omits `requestType` entirely on the
// agent (chat) path. Sending `requestType: "agent"` triggers a detail-free
// 429 RESOURCE_EXHAUSTED even with quota available.
if (!isAntigravity) {
// Keep safetySettings for Gemini CLI
envelope.request.safetySettings = geminiCLI.safetySettings;
}
@@ -305,7 +306,8 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
model: model,
userAgent: "antigravity",
requestId: `agent-${generateUUID()}`,
requestType: "agent",
// NOTE: official Antigravity client omits `requestType` on the agent (chat)
// path — see the note in wrapInCloudCodeEnvelope() above.
request: {
sessionId: toNumericSessionId(credentials?._clientSessionId) || deriveSessionId(credentials?.email || credentials?.connectionId),
contents: [],

View File

@@ -149,6 +149,13 @@ export function claudeToOpenAIResponse(chunk, state) {
if (chunk.delta?.stop_reason) {
state.finishReason = convertStopReason(chunk.delta.stop_reason);
// A refusal produces no content blocks at all. Surface Anthropic's own
// explanation as the message text so the client shows *why* the turn is
// empty instead of a blank reply.
const refusalNote = chunk.delta.stop_reason === "refusal" && chunk.delta.stop_details?.explanation;
if (refusalNote) {
results.push(createChunk(state, { content: refusalNote }));
}
const finalChunk = createChunk(state, {}, state.finishReason);
if (state.usage) {

View File

@@ -14,13 +14,47 @@ import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } fro
* Translate OpenAI chunk to Responses API events
* @returns {Array} Array of events with { event, data } structure
*/
// Upstream Chat Completions usage -> Responses API usage shape.
// Without this, /v1/responses never reports usage: Responses clients (Codex CLI)
// keep their "context used" gauge pinned at 0 and never auto-compact, so a long
// session grows until the upstream context limit rejects it (9router issue #3432).
//
// Note this is stored under state.responsesUsage, NOT state.usage: state.usage is
// owned by the stream layer, which fills it with normalizeUsage()-shaped counts
// (prompt_tokens/prompt_tokens_details) and hands it to finalizeStream() for
// logging and cost accounting. Overwriting it with this shape silently drops
// cached/reasoning tokens from those stats.
function toResponsesUsage(usage) {
if (!usage || typeof usage !== "object") return null;
const inputTokens = [usage.input_tokens, usage.prompt_tokens].find(Number.isFinite) ?? 0;
const outputTokens = [usage.output_tokens, usage.completion_tokens].find(Number.isFinite) ?? 0;
const responseUsage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: Number.isFinite(usage.total_tokens) ? usage.total_tokens : inputTokens + outputTokens
};
const cachedTokens = [usage.input_tokens_details?.cached_tokens, usage.prompt_tokens_details?.cached_tokens].find(Number.isFinite);
const reasoningTokens = [usage.output_tokens_details?.reasoning_tokens, usage.completion_tokens_details?.reasoning_tokens].find(Number.isFinite);
if (Number.isFinite(cachedTokens)) responseUsage.input_tokens_details = { cached_tokens: cachedTokens };
if (Number.isFinite(reasoningTokens)) responseUsage.output_tokens_details = { reasoning_tokens: reasoningTokens };
return responseUsage;
}
export function openaiToOpenAIResponsesResponse(chunk, state) {
if (!chunk) {
return flushEvents(state);
}
// Capture upstream usage BEFORE the choices guard below: the last OpenAI chunk
// may carry usage together with an empty choices array, and it must not be dropped.
if (chunk.usage) {
state.responsesUsage = toResponsesUsage(chunk.usage);
}
if (!chunk.choices?.length) return [];
const events = [];
const nextSeq = () => ++state.seq;
@@ -112,7 +146,19 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
closeReasoning(state, emit);
for (const i in state.funcCallIds) closeToolCall(state, emit, i);
sendCompleted(state, emit);
// Upstreams report usage either on the finish chunk itself or on a trailing chunk
// whose `choices` array is empty (OpenAI does the latter). Emitting
// response.completed here would freeze the payload before that trailing chunk is
// parsed, so when usage is not known yet we leave completion to flushEvents(),
// which runs once the upstream stream ends and by then has seen every chunk.
//
// That only holds on the direct openai:openai-responses route. When this converter
// runs as the second hop of a pivot (Claude/Gemini/Kiro upstream), translateResponse()
// drops the terminal null chunk before reaching us — the first hop returns null for
// it, leaving nothing to iterate — so flushEvents() is never called and deferring
// would swallow the terminal event entirely. Keep the old behaviour there.
const flushReachesUs = state.targetFormat === FORMATS.OPENAI;
if (state.responsesUsage || !flushReachesUs) sendCompleted(state, emit);
}
return events;
@@ -376,7 +422,8 @@ function sendCompleted(state, emit) {
created_at: state.created,
status: "completed",
background: false,
error: null
error: null,
...(state.responsesUsage ? { usage: state.responsesUsage } : {})
}
});
}

View File

@@ -14,6 +14,9 @@ export const CLAUDE_STOP = {
MAX_TOKENS: "max_tokens",
TOOL_USE: "tool_use",
STOP_SEQUENCE: "stop_sequence",
// Anthropic's API-level refusal (streaming classifier / ToS). Arrives in
// message_delta with zero output tokens; stop_details carries the reason.
REFUSAL: "refusal",
};
// Gemini finishReason values.

View File

@@ -218,6 +218,12 @@ export function encodeField(fieldNum, wireType, value) {
return concatArrays(tagBytes, lengthBytes, dataBytes);
}
if (wireType === WIRE_TYPE.FIXED64) {
const buf = Buffer.alloc(8);
buf.writeDoubleLE(Number(value));
return concatArrays(tagBytes, buf);
}
return new Uint8Array(0);
}
@@ -887,6 +893,211 @@ export function extractTextFromResponse(payload) {
}
}
// ==================== AGENT SERVICE (google.protobuf.Value + MCP) ====================
const PB_VALUE = { NULL: 1, NUMBER: 2, STRING: 3, BOOL: 4, STRUCT: 5, LIST: 6 };
const PB_STRUCT_FIELDS = 1;
const PB_MAP_KEY = 1;
const PB_MAP_VALUE = 2;
const PB_LIST_VALUES = 1;
const MTD_NAME = 1;
const MTD_DESCRIPTION = 2;
const MTD_INPUT_SCHEMA = 3;
const MTD_PROVIDER = 4;
const MTD_TOOL_NAME = 5;
const MCP_TOOLS_TOOL = 1;
const MCP_ARGS_NAME = 1;
const MCP_ARGS_ENTRY = 2;
const MCP_ARGS_CALL_ID = 3;
const MCP_ARGS_TOOL_NAME = 5;
const MCR_SUCCESS = 1;
const MCR_ERROR = 2;
const MCR_TOOL_NOT_FOUND = 5;
const MCS_CONTENT = 1;
const MCS_IS_ERROR = 2;
const MCC_TEXT = 1;
const MCC_IMAGE = 2;
const MTC_TEXT = 1;
const MIC_DATA = 1;
const MIC_MIME = 2;
const MER_MESSAGE = 1;
const TNF_NAME = 1;
function asBytes(value) {
if (!value) return Buffer.alloc(0);
return Buffer.isBuffer(value) ? value : Buffer.from(value);
}
/**
* Encode a JS value as google.protobuf.Value (oneof body, no outer tag).
*/
export function encodeAgentValue(value) {
if (value === null || value === undefined) {
return encodeField(PB_VALUE.NULL, WIRE_TYPE.VARINT, 0);
}
if (typeof value === "boolean") {
return encodeField(PB_VALUE.BOOL, WIRE_TYPE.VARINT, value ? 1 : 0);
}
if (typeof value === "number") {
return encodeField(PB_VALUE.NUMBER, WIRE_TYPE.FIXED64, value);
}
if (typeof value === "string") {
return encodeField(PB_VALUE.STRING, WIRE_TYPE.LEN, value);
}
if (Array.isArray(value)) {
const items = value.map((item) => encodeField(PB_LIST_VALUES, WIRE_TYPE.LEN, encodeAgentValue(item)));
return encodeField(PB_VALUE.LIST, WIRE_TYPE.LEN, concatArrays(...items));
}
if (typeof value === "object") {
const entries = Object.entries(value).map(([key, val]) => encodeField(
PB_STRUCT_FIELDS,
WIRE_TYPE.LEN,
concatArrays(
encodeField(PB_MAP_KEY, WIRE_TYPE.LEN, key),
encodeField(PB_MAP_VALUE, WIRE_TYPE.LEN, encodeAgentValue(val)),
),
));
return encodeField(PB_VALUE.STRUCT, WIRE_TYPE.LEN, concatArrays(...entries));
}
return encodeField(PB_VALUE.STRING, WIRE_TYPE.LEN, String(value));
}
/**
* Decode google.protobuf.Value bytes back to a JS value.
*/
export function decodeAgentValue(bytes) {
const fields = decodeMessage(asBytes(bytes));
if (fields.has(PB_VALUE.NULL)) return null;
if (fields.has(PB_VALUE.BOOL)) return fields.get(PB_VALUE.BOOL)[0].value !== 0;
if (fields.has(PB_VALUE.NUMBER)) {
return asBytes(fields.get(PB_VALUE.NUMBER)[0].value).readDoubleLE(0);
}
if (fields.has(PB_VALUE.STRING)) {
return asBytes(fields.get(PB_VALUE.STRING)[0].value).toString("utf8");
}
if (fields.has(PB_VALUE.STRUCT)) {
const result = {};
for (const entry of decodeMessage(asBytes(fields.get(PB_VALUE.STRUCT)[0].value)).get(PB_STRUCT_FIELDS) || []) {
const pair = decodeMessage(asBytes(entry.value));
const key = asBytes(pair.get(PB_MAP_KEY)?.[0]?.value).toString("utf8");
if (key) result[key] = decodeAgentValue(pair.get(PB_MAP_VALUE)?.[0]?.value);
}
return result;
}
if (fields.has(PB_VALUE.LIST)) {
return (decodeMessage(asBytes(fields.get(PB_VALUE.LIST)[0].value)).get(PB_LIST_VALUES) || [])
.map((item) => decodeAgentValue(item.value));
}
return null;
}
function toolNameAndSchema(tool) {
const fn = tool?.function || tool || {};
return {
name: fn.name || tool?.name || "",
description: fn.description || tool?.description || "",
schema: fn.parameters || tool?.parameters || tool?.inputSchema || tool?.input_schema || {},
};
}
/**
* Encode agent.v1.McpToolDefinition body (name, description, Value schema, provider, tool_name).
*/
export function encodeMcpToolDefinition(tool) {
const { name, description, schema } = toolNameAndSchema(tool);
return concatArrays(
encodeField(MTD_NAME, WIRE_TYPE.LEN, name),
encodeField(MTD_DESCRIPTION, WIRE_TYPE.LEN, description),
encodeField(MTD_INPUT_SCHEMA, WIRE_TYPE.LEN, encodeAgentValue(schema)),
encodeField(MTD_PROVIDER, WIRE_TYPE.LEN, "9router"),
encodeField(MTD_TOOL_NAME, WIRE_TYPE.LEN, name),
);
}
/**
* Encode AgentRunRequest.mcp_tools: repeated McpToolDefinition under field 1.
*/
export function encodeMcpTools(tools = []) {
if (!tools?.length) return new Uint8Array();
return concatArrays(
...tools.map((tool) => encodeField(MCP_TOOLS_TOOL, WIRE_TYPE.LEN, encodeMcpToolDefinition(tool))),
);
}
/**
* Decode agent.v1.McpArgs (name, typed args map, toolCallId, toolName).
*/
export function decodeMcpArgs(bytes) {
const msg = decodeMessage(asBytes(bytes));
const args = {};
for (const entry of msg.get(MCP_ARGS_ENTRY) || []) {
const pair = decodeMessage(asBytes(entry.value));
const key = asBytes(pair.get(PB_MAP_KEY)?.[0]?.value).toString("utf8");
if (key) args[key] = decodeAgentValue(pair.get(PB_MAP_VALUE)?.[0]?.value);
}
const read = (field) => asBytes(msg.get(field)?.[0]?.value).toString("utf8");
return {
name: read(MCP_ARGS_NAME),
toolCallId: read(MCP_ARGS_CALL_ID),
toolName: read(MCP_ARGS_TOOL_NAME),
args,
};
}
function encodeMcpTextItem(text) {
return encodeField(
MCS_CONTENT,
WIRE_TYPE.LEN,
encodeField(MCC_TEXT, WIRE_TYPE.LEN, encodeField(MTC_TEXT, WIRE_TYPE.LEN, text)),
);
}
function encodeMcpImageItem(image) {
const data = image?.data || image || new Uint8Array();
const mimeType = image?.mimeType || "application/octet-stream";
return encodeField(
MCS_CONTENT,
WIRE_TYPE.LEN,
encodeField(
MCC_IMAGE,
WIRE_TYPE.LEN,
concatArrays(
encodeField(MIC_DATA, WIRE_TYPE.LEN, data),
encodeField(MIC_MIME, WIRE_TYPE.LEN, mimeType),
),
),
);
}
export function encodeMcpResultSuccess({ textItems = [], imageItems = [], isError = false } = {}) {
const success = concatArrays(
...textItems.map(encodeMcpTextItem),
...imageItems.map(encodeMcpImageItem),
encodeField(MCS_IS_ERROR, WIRE_TYPE.VARINT, isError ? 1 : 0),
);
return encodeField(MCR_SUCCESS, WIRE_TYPE.LEN, success);
}
export function encodeMcpResultError(message) {
return encodeField(
MCR_ERROR,
WIRE_TYPE.LEN,
encodeField(MER_MESSAGE, WIRE_TYPE.LEN, String(message || "")),
);
}
export function encodeMcpResultToolNotFound(name) {
return encodeField(
MCR_TOOL_NOT_FOUND,
WIRE_TYPE.LEN,
encodeField(TNF_NAME, WIRE_TYPE.LEN, String(name || "")),
);
}
// ==================== EXPORTS ====================
export default {
@@ -900,5 +1111,13 @@ export default {
decodeField,
decodeMessage,
parseConnectRPCFrame,
extractTextFromResponse
extractTextFromResponse,
encodeAgentValue,
decodeAgentValue,
encodeMcpToolDefinition,
encodeMcpTools,
decodeMcpArgs,
encodeMcpResultSuccess,
encodeMcpResultError,
encodeMcpResultToolNotFound,
};

View File

@@ -0,0 +1,232 @@
/**
* Helpers for the OpenCode Zen free-tier client fingerprint.
*
* Live upstream probes show that free-tier requests must include the lowercase
* file-search quartet (bash/glob/grep/read). Agent clients such as Claude Code
* may declare the same tools with different casing, so those case variants must
* be renamed instead of duplicated. The response side restores the caller's
* original spelling so downstream clients still recognise their own tool calls.
*/
/** Canonical names required by the upstream free-tier gate. */
export const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"];
// Request body -> names renamed for that request. transformRequest() mutates the
// same body object that chatCore passed into the executor, so a WeakMap keeps the
// mapping request-local without putting transport metadata on the wire.
const renamedToolNames = new WeakMap();
/** Canonical lowercase name when `name` is a quartet member; "" otherwise. */
export function fingerprintToolKey(name) {
const lower = String(name ?? "").trim().toLowerCase();
return OPENCODE_FINGERPRINT_TOOLS.includes(lower) ? lower : "";
}
/** Read a tool name from either flat ({name}) or chat ({function:{name}}) shape. */
function toolNameOf(tool) {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
if (typeof tool.name === "string" && tool.name.trim()) return tool.name.trim();
const fn = tool.function;
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") {
return fn.name.trim();
}
return "";
}
/**
* Canonicalise only the fingerprint quartet and remove duplicate quartet
* variants. Non-fingerprint tools are preserved verbatim, including tools whose
* names differ only by case; they are outside OpenCode's fingerprint contract.
*
* @param {Array} tools
* @returns {{ tools: Array, map: Map<string,string> }} map: sent name -> original name
*/
export function concealFingerprintToolNames(tools) {
const map = new Map();
if (!Array.isArray(tools) || tools.length === 0) return { tools, map };
const seenQuartet = new Set();
const out = [];
for (const tool of tools) {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
out.push(tool);
continue;
}
const current = toolNameOf(tool);
const key = fingerprintToolKey(current);
if (!key) {
out.push(tool);
continue;
}
// `Bash` + `bash` is rejected upstream as a duplicate. Keep exactly one
// declaration for each quartet member.
if (seenQuartet.has(key)) continue;
seenQuartet.add(key);
if (current !== key) {
map.set(key, current);
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function)
? tool.function
: null;
out.push(fn ? { ...tool, function: { ...fn, name: key } } : { ...tool, name: key });
} else {
out.push(tool);
}
}
return { tools: out, map };
}
/** Append only genuinely missing quartet declarations. */
export function appendMissingFingerprintTools(tools, flat) {
const list = Array.isArray(tools) ? tools : [];
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
if (list.some((tool) => fingerprintToolKey(toolNameOf(tool)) === name)) continue;
list.push(flat ? {
type: "function",
name,
description: "This tool is currently unavailable and must not be used.",
parameters: { type: "object", properties: {} },
} : {
type: "function",
function: {
name,
description: "This tool is currently unavailable and must not be used.",
parameters: { type: "object", properties: {} },
},
});
}
return list;
}
/** Point an explicit tool_choice at a quartet member after canonicalisation. */
export function retargetToolChoice(body, map) {
if (!body || typeof body !== "object" || !map?.size) return;
const choice = body.tool_choice;
if (!choice || typeof choice !== "object" || Array.isArray(choice)) return;
if (typeof choice.name === "string") {
const key = fingerprintToolKey(choice.name);
if (key && map.has(key)) body.tool_choice = { ...choice, name: key };
return;
}
const fn = choice.function;
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") {
const key = fingerprintToolKey(fn.name);
if (key && map.has(key)) {
body.tool_choice = { ...choice, function: { ...fn, name: key } };
}
}
}
/**
* Full request-side pass: canonicalise quartet case variants, remove duplicate
* quartet declarations, append missing members and preserve the legacy
* tool_choice defaults used by the OpenCode executor.
*
* @param {object} body
* @param {boolean} flat - true for Responses tools ({name}), false for chat tools
* @returns {Map<string,string>} map: sent name -> original name
*/
export function applyFingerprintTools(body, flat) {
if (!body || typeof body !== "object") return new Map();
const hadClientTools = Array.isArray(body.tools) && body.tools.length > 0;
const { tools, map } = concealFingerprintToolNames(body.tools);
body.tools = appendMissingFingerprintTools(tools, flat);
retargetToolChoice(body, map);
// Preserve the existing executor semantics. Responses uses auto when the
// fingerprint helper supplies tools; chat requests with no caller tools use
// none so the injected decoys cannot be selected.
if (!body.tool_choice) {
if (flat) body.tool_choice = "auto";
else if (!hadClientTools) body.tool_choice = "none";
}
recordRenamedToolNames(body, map);
return map;
}
/** Store the rename map for `body`. */
export function recordRenamedToolNames(body, map) {
if (!body || typeof body !== "object" || !map?.size) return;
renamedToolNames.set(body, map);
}
/** Retrieve the rename map for `body`. */
export function takeRenamedToolNames(body) {
if (!body || typeof body !== "object") return null;
return renamedToolNames.get(body) || null;
}
// Response side -------------------------------------------------------------
/** Restore caller tool spellings in supported response/event shapes. */
export function restoreToolNames(payload, map) {
if (!map?.size || !payload) return payload;
if (Array.isArray(payload)) return payload.map((item) => restoreToolNames(item, map));
if (typeof payload !== "object") return payload;
let out = payload;
const put = (key, value) => {
if (out === payload) out = { ...payload };
out[key] = value;
};
// Claude streaming content_block_start event.
if (payload.type === "content_block_start") {
const block = payload.content_block;
if (block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)) {
put("content_block", { ...block, name: map.get(block.name) });
}
}
// Claude non-streaming message body.
if (Array.isArray(payload.content)) {
put("content", payload.content.map((block) =>
block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)
? { ...block, name: map.get(block.name) }
: block));
}
// OpenAI Chat Completions, both streaming delta and JSON message shapes.
if (Array.isArray(payload.choices)) {
put("choices", payload.choices.map((choice) => {
let changed = false;
const next = { ...choice };
for (const holder of ["delta", "message"]) {
const value = choice?.[holder];
if (!value || !Array.isArray(value.tool_calls) || value.tool_calls.length === 0) continue;
const calls = value.tool_calls.map((call) => {
const name = call?.function?.name;
if (typeof name === "string" && map.has(name)) {
changed = true;
return { ...call, function: { ...call.function, name: map.get(name) } };
}
return call;
});
next[holder] = { ...value, tool_calls: calls };
}
return changed ? next : choice;
}));
}
// OpenAI Responses final JSON body.
if (Array.isArray(payload.output)) {
put("output", payload.output.map((item) =>
item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)
? { ...item, name: map.get(item.name) }
: item));
}
// OpenAI Responses SSE events such as response.output_item.added/done.
const item = payload.item;
if (item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)) {
put("item", { ...item, name: map.get(item.name) });
}
return out;
}

View File

@@ -215,8 +215,11 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl);
if (vercelRelayUrl) {
const parsed = new URL(targetUrl);
const baseHeaders = options.headers instanceof Headers
? Object.fromEntries(options.headers.entries())
: { ...(options.headers || {}) };
const relayHeaders = {
...options.headers,
...baseHeaders,
"x-relay-target": `${parsed.protocol}//${parsed.host}`,
"x-relay-path": `${parsed.pathname}${parsed.search}`,
};

View File

@@ -60,7 +60,13 @@ export function createSSEStream(options = {}) {
const decoder = new TextDecoder("utf-8", { fatal: false });
const state = mode === STREAM_MODE.TRANSLATE
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, sessionId: credentials?._clientSessionId || null }
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, sessionId: credentials?._clientSessionId || null,
// Which upstream format this stream came from. A response translator can be
// reached either directly (target === its registered source) or as the second
// hop of a pivot, and on the terminal null chunk the pivot drops it — so a
// translator that defers closing events until flush needs to know which case
// it is in. Absent/undefined means "unknown", i.e. do not defer.
targetFormat }
: null;
let totalContentLength = 0;

View File

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

View File

@@ -1390,5 +1390,30 @@
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。",
"✓ Confirm Add": "✓ 确认添加",
"📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量",
"🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。"
"🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 需要 OAuth。立即添加并在应用后认证;工具列表将在首次连接后自动发现。",
"Reading local MiMo Desktop credentials...": "正在读取本地 MiMo 桌面版凭证...",
"Desktop Plan · Local credentials": "Desktop Plan · 本地凭证",
"This account is already connected (no need to import again)": "该账号已连接(无需重复导入)",
"Untested": "未测试",
"Re-sync local credentials": "重新同步本地凭证",
"Connect with local credentials": "使用本地凭证连接",
"or": "或",
"Browser Login": "网页登录",
"No Desktop required": "无需桌面客户端",
"Weekly quota": "周额度",
"Waiting for login...": "等待登录中...",
"Reopen login window": "重新打开登录窗口",
"Choose cluster & sign in": "选择集群并登录",
"Login session expired — please retry.": "登录会话已过期,请重试。",
"Failed to save credentials": "保存凭据失败",
"Import failed": "导入失败",
"No local Desktop credentials found": "未检测到本地桌面凭证",
"You can still sign in via browser — no Desktop client needed.": "仍可通过网页登录,无需桌面客户端。",
"Select account cluster": "选择小米账号集群",
"Choose the region cluster of your Xiaomi account:": "请选择你的小米账号所在地区集群:",
"China (Mainland)": "中国大陆",
"Singapore": "新加坡",
"Europe · Amsterdam": "欧洲 · 阿姆斯特丹",
"Russia": "俄罗斯",
"India": "印度"
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/providers/crush.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

BIN
public/providers/forge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/providers/omp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

6
public/providers/pi.svg Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z"/>
<path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z"/>
<path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

101
public/providers/smelt.svg Normal file
View File

@@ -0,0 +1,101 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 12.1" shape-rendering="crispEdges" role="img" aria-label="smelt logo">
<rect x="6" y="0" width="1" height="1.1" fill="#af0000"/>
<rect x="6" y="1.1" width="1" height="1.1" fill="#ff5f00"/>
<rect x="7" y="1.1" width="1" height="1.1" fill="#ff5f00"/>
<rect x="5" y="2.2" width="1" height="1.1" fill="#af0000"/>
<rect x="6" y="2.2" width="1" height="1.1" fill="#ff5f00"/>
<rect x="7" y="2.2" width="1" height="1.1" fill="#ff8700"/>
<rect x="8" y="2.2" width="1" height="1.1" fill="#ff8700"/>
<rect x="9" y="2.2" width="1" height="1.1" fill="#ff5f00"/>
<rect x="10" y="2.2" width="1" height="1.1" fill="#af0000"/>
<rect x="4" y="3.3" width="1" height="1.1" fill="#af0000"/>
<rect x="5" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
<rect x="6" y="3.3" width="1" height="1.1" fill="#ff8700"/>
<rect x="7" y="3.3" width="1" height="1.1" fill="#ffd700"/>
<rect x="8" y="3.3" width="1" height="1.1" fill="#ffd700"/>
<rect x="9" y="3.3" width="1" height="1.1" fill="#ff8700"/>
<rect x="10" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
<rect x="11" y="3.3" width="1" height="1.1" fill="#af0000"/>
<rect x="13" y="3.3" width="1" height="1.1" fill="#af0000"/>
<rect x="14" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
<rect x="3" y="4.4" width="1" height="1.1" fill="#af0000"/>
<rect x="4" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
<rect x="5" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="6" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="7" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="8" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="9" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="10" y="4.4" width="1" height="1.1" fill="#ffd700"/>
<rect x="11" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="12" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
<rect x="13" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="14" y="4.4" width="1" height="1.1" fill="#ff8700"/>
<rect x="15" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
<rect x="1" y="5.5" width="1" height="1.1" fill="#af0000"/>
<rect x="2" y="5.5" width="1" height="1.1" fill="#ff5f00"/>
<rect x="3" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="4" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="5" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="6" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="7" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="8" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="9" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="10" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="11" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="12" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="13" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="14" y="5.5" width="1" height="1.1" fill="#ffd700"/>
<rect x="15" y="5.5" width="1" height="1.1" fill="#ff8700"/>
<rect x="16" y="5.5" width="1" height="1.1" fill="#ff5f00"/>
<rect x="17" y="5.5" width="1" height="1.1" fill="#af0000"/>
<rect x="0" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="1" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="2" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="4" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="5" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="6" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="7" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="8" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="11" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="12" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="14" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="17" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="18" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="19" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
<rect x="0" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="1" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="2" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="4" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="5" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="6" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="7" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="8" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="11" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="12" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="14" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="17" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="18" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
<rect x="19" y="8.8" width="1" height="1.1" fill="#9b8880"/>
<rect x="2" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="4" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="6" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="8" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="14" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="18" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
<rect x="0" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="1" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="2" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="4" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="6" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="8" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="10" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="11" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="12" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="14" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="15" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="18" y="11" width="1" height="1.1" fill="#fff6ef"/>
<rect x="19" y="11" width="1" height="1.1" fill="#fff6ef"/>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -9,7 +9,7 @@ import {
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
JcodeToolCard, GrokBuildToolCard,
JcodeToolCard, GrokBuildToolCard, GenericCliToolCard,
} from "../components";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -166,6 +166,13 @@ export default function ToolDetailClient({ toolId, machineId }) {
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "grok-build":
return <GrokBuildToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
case "pi":
case "omp":
case "crush":
case "forge":
case "smelt":
case "codewhale":
return <GenericCliToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
default:
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
}

View File

@@ -0,0 +1,636 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import BaseUrlSelect from "./BaseUrlSelect";
import { rememberEndpoint } from "./cliEndpointPresets";
import ApiKeySelect from "./ApiKeySelect";
import { matchKnownEndpoint } from "./cliEndpointMatch";
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
export default function GenericCliToolCard({
tool,
isExpanded,
onToggle,
baseUrl,
apiKeys,
activeProviders = [],
cloudEnabled,
initialStatus,
tunnelEnabled,
tunnelPublicUrl,
tailscaleEnabled,
tailscaleUrl,
}) {
const [status, setStatus] = useState(() => initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
const [selectedModel, setSelectedModel] = useState(() => {
const cfg = initialStatus?.config;
return cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id || "";
});
const [selectedModels, setSelectedModels] = useState(() => {
const cfg = initialStatus?.config;
const list = cfg?.providers?.["9router"]?.models;
if (Array.isArray(list) && list.length > 0) {
return list.map((m) => (typeof m === "string" ? m : m.id));
}
return [];
});
const [modalOpen, setModalOpen] = useState(false);
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState("");
const endpointUrl = `/api/cli-tools/${tool.id}-settings`;
useEffect(() => {
let active = true;
if (isExpanded && !initialStatus) {
fetch(endpointUrl)
.then((res) => res.json())
.then((data) => {
if (active) {
setStatus(data);
const cfg = data?.config;
if (tool.id === "pi") {
const list = cfg?.providers?.["9router"]?.models;
if (Array.isArray(list) && list.length > 0) {
const ids = list.map((m) => (typeof m === "string" ? m : m.id));
setSelectedModels(ids);
}
} else {
const mod = cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id;
if (mod) setSelectedModel((prev) => prev || mod);
}
}
})
.catch((error) => {
if (active) setStatus({ installed: false, error: error.message });
});
}
return () => {
active = false;
};
}, [isExpanded, initialStatus, endpointUrl, tool.id]);
const checkStatus = async () => {
setChecking(true);
try {
const res = await fetch(endpointUrl);
const data = await res.json();
setStatus(data);
const cfg = data?.config;
if (tool.id === "pi") {
const list = cfg?.providers?.["9router"]?.models;
if (Array.isArray(list) && list.length > 0) {
const ids = list.map((m) => (typeof m === "string" ? m : m.id));
setSelectedModels(ids);
}
} else {
const mod = cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id;
if (mod && !selectedModel) setSelectedModel(mod);
}
} catch (error) {
setStatus({ installed: false, error: error.message });
} finally {
setChecking(false);
}
};
const getEffectiveBaseUrl = () => {
const url = customBaseUrl || `${baseUrl}/v1`;
return url.endsWith("/v1") ? url : `${url}/v1`;
};
const getCurrentBaseUrl = () => {
if (!status?.config) return "";
const cfg = status.config;
if (typeof cfg.baseUrl === "string") return cfg.baseUrl;
if (typeof cfg.openai?.base_url === "string") return cfg.openai.base_url;
if (typeof cfg.providers?.["9router"]?.base_url === "string") return cfg.providers["9router"].base_url;
if (typeof cfg.providers?.["9router"]?.baseUrl === "string") return cfg.providers["9router"].baseUrl;
return "";
};
const currentBaseUrl = getCurrentBaseUrl();
const getConfigStatus = () => {
if (!status?.installed) return null;
if (!status.has9Router) return "not_configured";
if (currentBaseUrl && matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl })) {
return "configured";
}
return "configured";
};
const configStatus = getConfigStatus();
const handleApply = async () => {
setApplying(true);
setMessage(null);
try {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const payload = {
baseUrl: getEffectiveBaseUrl(),
apiKey: keyToUse,
};
if (tool.id === "pi") {
payload.models = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
} else {
payload.model = selectedModel;
}
const res = await fetch(endpointUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
rememberEndpoint(getEffectiveBaseUrl());
setMessage({ type: "success", text: data.message || "Settings applied successfully!" });
await checkStatus();
} else {
setMessage({ type: "error", text: data.error?.message || "Failed to apply settings." });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleRestore = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch(endpointUrl, { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || "Settings removed successfully." });
await checkStatus();
} else {
setMessage({ type: "error", text: data.error?.message || "Failed to reset settings." });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleSelectModel = (model) => {
if (tool.id === "pi") {
if (!selectedModels.includes(model.value)) {
setSelectedModels((prev) => [...prev, model.value]);
}
} else {
setSelectedModel(model.value);
}
setModalOpen(false);
};
const handleAddAllActiveModels = () => {
const allModels = [];
activeProviders.forEach((conn) => {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
const providerModels = getModelsByProviderId(conn.provider);
providerModels.forEach((m) => {
const val = `${alias}/${m.id}`;
if (!allModels.includes(val)) allModels.push(val);
});
});
if (allModels.length > 0) {
setSelectedModels((prev) => Array.from(new Set([...prev, ...allModels])));
}
};
const handleRemoveModel = (modelToRemove) => {
setSelectedModels((prev) => prev.filter((m) => m !== modelToRemove));
};
const getInstallCommand = () => {
switch (tool.id) {
case "pi":
return "curl -fsSL https://pi.dev/install.sh | sh # or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent";
case "omp":
return "npm install -g oh-my-pi";
case "crush":
return "brew install charmbracelet/tap/crush # or go install github.com/charmbracelet/crush@latest";
case "forge":
return "cargo install forgecode";
case "smelt":
return "cargo install smelt";
case "codewhale":
return "cargo install codewhale";
default:
return `npm install -g ${tool.id}`;
}
};
const getManualConfigContent = () => {
const effectiveUrl = getEffectiveBaseUrl();
const key = selectedApiKey || "sk_9router";
const mod = selectedModel || "provider/model-id";
switch (tool.id) {
case "pi": {
const modelsList = selectedModels.length > 0 ? selectedModels : [mod];
return [
{
filename: "~/.pi/agent/models.json",
content: JSON.stringify(
{
providers: {
"9router": {
baseUrl: effectiveUrl,
apiKey: key,
api: "openai-completions",
models: modelsList.map((id) => ({
id,
name: id,
contextWindow: 128000,
maxTokens: 16384,
})),
},
},
},
null,
2
),
},
];
}
case "omp":
return [
{
filename: "~/.omp/agent/models.yml",
content: `providers:\n 9router:\n baseUrl: ${effectiveUrl}\n apiKey: ${key}\n api: openai-completions\n authHeader: true\n disableStrictTools: true\n discovery:\n type: proxy`,
},
];
case "crush":
return [
{
filename: "~/.config/crush/crush.json",
content: JSON.stringify(
{
providers: {
"9router": {
type: "openai-compat",
base_url: effectiveUrl,
api_key: key,
models: [{ id: mod, name: mod, context_window: 128000 }],
},
},
},
null,
2
),
},
];
case "forge":
return [
{
filename: "~/.forge/config.toml",
content: `# Forge config — managed by 9Router\n\n[openai]\napi_key = "${key}"\nbase_url = "${effectiveUrl}"\nmodel = "${mod}"`,
},
];
case "smelt":
return [
{
filename: "~/.smelt/config.json",
content: JSON.stringify({ baseUrl: effectiveUrl, apiKey: key, model: mod, _managedBy: "9router" }, null, 2),
},
];
case "codewhale":
return [
{
filename: "~/.codewhale/config.toml",
content: `# CodeWhale config — managed by 9Router\n\n[openai]\nbase_url = "${effectiveUrl}"\napi_key = "${key}"\nmodel = "${mod}"`,
},
];
default:
return [];
}
};
return (
<Card padding="xs" className="overflow-hidden">
{/* Header clickable */}
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
<div className="flex min-w-0 items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
{tool.image ? (
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => { e.target.style.display = "none"; }}
loading="lazy"
decoding="async"
/>
) : tool.icon ? (
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>
{tool.icon}
</span>
) : (
<span className="material-symbols-outlined text-[28px] text-primary">terminal</span>
)}
</div>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="font-medium text-sm">{tool.name}</h3>
{configStatus === "configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
Connected
</span>
)}
{configStatus === "not_configured" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
Not configured
</span>
)}
{configStatus === "other" && (
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
Other
</span>
)}
</div>
<p className="text-xs text-text-muted truncate">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>
expand_more
</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checking && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking {tool.name}...</span>
</div>
)}
{!checking && status && !status.installed && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">{tool.name} not detected locally</p>
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
</div>
</div>
<div className="flex items-center gap-2 pl-9">
<Button
variant="secondary"
size="sm"
onClick={() => setShowManualConfigModal(true)}
className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30"
>
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
Manual Config
</Button>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">Install command:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">{getInstallCommand()}</code>
</div>
{tool.docsUrl && (
<p className="text-xs text-text-muted">
Docs: <a href={tool.docsUrl} target="_blank" rel="noreferrer" className="text-primary hover:underline">{tool.docsUrl}</a>
</p>
)}
</div>
</div>
)}
</div>
)}
{!checking && status?.installed && (
<>
<div className="flex flex-col gap-2">
{/* Endpoint (selector) */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<BaseUrlSelect
value={customBaseUrl || getEffectiveBaseUrl()}
onChange={setCustomBaseUrl}
requiresExternalUrl={tool.requiresExternalUrl}
tunnelEnabled={tunnelEnabled}
tunnelPublicUrl={tunnelPublicUrl}
tailscaleEnabled={tailscaleEnabled}
tailscaleUrl={tailscaleUrl}
currentUrl={currentBaseUrl}
/>
</div>
{/* Current configured */}
{currentBaseUrl ? (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
{currentBaseUrl}
</span>
</div>
) : null}
{/* API Key */}
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
</div>
{/* Models selector cho Pi (multi-models) */}
{tool.id === "pi" && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm mt-1">Models</span>
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline mt-1.5">arrow_forward</span>
<div className="flex-1 flex flex-col gap-2">
<div className="flex flex-wrap gap-1.5 min-h-[36px] p-2 bg-surface rounded border border-border">
{selectedModels.length === 0 ? (
<span className="text-xs text-text-muted italic">No models selected. Add models to use in Pi.</span>
) : (
selectedModels.map((modelId) => (
<span
key={modelId}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-bg-secondary text-xs text-text-main border border-border"
>
<span>{modelId}</span>
<button
type="button"
onClick={() => handleRemoveModel(modelId)}
className="text-text-muted hover:text-red-500 rounded p-0.5"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setModalOpen(true)}
disabled={!activeProviders?.length}
>
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
Add Model
</Button>
{activeProviders?.length > 0 && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={handleAddAllActiveModels}
className="text-xs text-primary hover:text-primary-hover"
>
+ Add All Active Models
</Button>
)}
{selectedModels.length > 0 && (
<button
type="button"
onClick={() => setSelectedModels([])}
className="text-xs text-text-muted hover:text-red-500 ml-auto"
>
Clear all
</button>
)}
</div>
</div>
</div>
)}
{/* Model (1 model cho các tool khác, trừ omp) */}
{tool.id !== "omp" && tool.id !== "pi" && (
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</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={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>
)}
</div>
<button
onClick={() => setModalOpen(true)}
disabled={!activeProviders?.length}
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 ${
activeProviders?.length
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
</button>
</div>
)}
</div>
{/* Messages */}
{message && (
<div
className={`p-3 rounded-lg text-sm ${
message.type === "success"
? "bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
}`}
>
{message.text}
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={handleApply}
disabled={applying || checking}
>
{applying ? "Applying..." : "Apply Settings"}
</Button>
{status?.has9Router && (
<Button
variant="outline"
size="sm"
onClick={handleRestore}
disabled={restoring || checking}
className="text-red-500 hover:text-red-600 hover:border-red-500/50"
>
{restoring ? "Removing..." : "Remove from Tool"}
</Button>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowManualConfigModal(true)}
>
<span className="material-symbols-outlined text-[18px] mr-1">code</span>
Manual Config
</Button>
</div>
</>
)}
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleSelectModel}
activeProviders={activeProviders}
/>
<ManualConfigModal
isOpen={showManualConfigModal}
onClose={() => setShowManualConfigModal(false)}
title={`${tool.name} Configuration`}
configs={getManualConfigContent()}
/>
</Card>
);
}

View File

@@ -13,6 +13,7 @@ export { default as KiloToolCard } from "./KiloToolCard";
export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
export { default as JcodeToolCard } from "./JcodeToolCard";
export { default as GrokBuildToolCard } from "./GrokBuildToolCard";
export { default as GenericCliToolCard } from "./GenericCliToolCard";
export { default as MitmServerCard } from "./MitmServerCard";
export { default as MitmToolCard } from "./MitmToolCard";
export { default as MitmLinkCard } from "./MitmLinkCard";

View File

@@ -8,7 +8,7 @@ import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifi
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ModelSelectSidePanel, ConfirmModal, CapacityBadges, Select, Toggle, TagInput } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { aggregateComboCapabilities } from "open-sse/providers/capabilities.js";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
@@ -17,11 +17,11 @@ const VALID_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/;
// A request needing a capability the target model/combo lacks switches straight
// to the first enabled model here instead of erroring or dropping the data.
const CAPACITY_ADAPTER_CAPS = [
{ key: "vision", label: "Vision", icon: "visibility", desc: "Images" },
{ key: "vision", label: "Vision", icon: "visibility", desc: "images (png, jpg, webp, …)" },
// pdf, videoInput temporarily hidden — no translator support yet for those blocks.
{ key: "audioInput", label: "Audio", icon: "graphic_eq", desc: "Audio input" },
{ key: "audioInput", label: "Audio", icon: "graphic_eq", desc: "audio input" },
];
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free";
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.6-flash-free";
const EMPTY_CAP_ENTRY = { enabled: true, roundRobin: false, models: [] };
const EMPTY_CAPACITY_ADAPTER = {
vision: { ...EMPTY_CAP_ENTRY },
@@ -29,21 +29,29 @@ const EMPTY_CAPACITY_ADAPTER = {
audioInput: { ...EMPTY_CAP_ENTRY },
videoInput: { ...EMPTY_CAP_ENTRY },
};
const upgradeLegacyModel = (m) => (m === "oc/mimo-v2.5-free" ? DEFAULT_FALLBACK_MODEL : m);
// Backward-compat: legacy stored form was an array of {model, enabled}.
function normalizeCapEntry(entry) {
if (Array.isArray(entry)) {
return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) };
return { enabled: true, roundRobin: false, models: entry.map((e) => upgradeLegacyModel(e?.model || e)).filter(Boolean) };
}
if (entry && typeof entry === "object") {
return {
enabled: entry.enabled !== false,
roundRobin: !!entry.roundRobin,
models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [],
models: Array.isArray(entry.models) ? entry.models.map(upgradeLegacyModel).filter(Boolean) : [],
};
}
return { ...EMPTY_CAP_ENTRY };
}
const STRATEGY_OPTIONS = [
{ value: "fallback", label: "Fallback — try in order" },
{ value: "round-robin", label: "Round Robin — rotate" },
{ value: "fusion", label: "Fusion — panel + judge" },
];
export default function CombosPage() {
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
@@ -55,6 +63,9 @@ export default function CombosPage() {
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
const { getCaps } = useModelCaps();
const [confirmState, setConfirmState] = useState(null);
const [presetLoading, setPresetLoading] = useState(null); // "cursor" | "claude" | null
const [selectedIds, setSelectedIds] = useState([]);
const [bulkBusy, setBulkBusy] = useState(false);
const { copied, copy } = useCopyToClipboard();
// Reorder sensors: small activation distance keeps click-to-edit, drag-to-reorder.
const sensors = useSensors(
@@ -69,6 +80,88 @@ export default function CombosPage() {
fetchData();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Drop stale selection when the combo list changes (delete / refresh).
useEffect(() => {
const alive = new Set(combos.map((c) => c.id));
setSelectedIds((prev) => prev.filter((id) => alive.has(id)));
}, [combos]);
const selectedCombos = combos.filter((c) => selectedIds.includes(c.id));
const allSelected = combos.length > 0 && selectedIds.length === combos.length;
const someSelected = selectedIds.length > 0;
const toggleSelect = (id) => {
setSelectedIds((prev) => (
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
));
};
const toggleSelectAll = () => {
setSelectedIds(allSelected ? [] : combos.map((c) => c.id));
};
const clearSelection = () => setSelectedIds([]);
const handleGeneratePresets = async (source) => {
const label = source === "cursor" ? "Cursor Default" : "Claude Default";
setPresetLoading(source);
try {
const previewRes = await fetch(`/api/combos/presets?source=${source}`);
const preview = await previewRes.json();
if (!previewRes.ok) {
alert(preview.error || `Failed to preview ${label}`);
return;
}
const toCreate = preview.toCreate ?? (preview.items || []).filter((i) => !i.exists).length;
const toSkip = preview.toSkip ?? (preview.items || []).filter((i) => i.exists).length;
const total = (preview.items || []).length;
if (total === 0) {
alert(`No ${label} models available to generate.`);
return;
}
if (toCreate === 0) {
alert(`All ${total} ${label} combos already exist. Nothing to create.`);
return;
}
setConfirmState({
title: `Generate ${label}`,
message: `Create ${toCreate} combo${toCreate === 1 ? "" : "s"} named like ${source === "cursor" ? "Cursor" : "Claude"} model IDs (seeded with cu/… or cc/…). ${toSkip} already exist and will be skipped. You can edit any combo afterward to add fallbacks.`,
confirmText: "Generate",
variant: "primary",
onConfirm: async () => {
setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
try {
const res = await fetch("/api/combos/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source }),
});
const data = await res.json();
if (!res.ok) {
alert(data.error || `Failed to generate ${label}`);
return;
}
await fetchData();
setConfirmState(null);
} catch (error) {
console.log(`Error generating ${label}:`, error);
alert(`Failed to generate ${label}`);
setConfirmState((prev) => prev ? { ...prev, loading: false } : null);
}
},
});
} catch (error) {
console.log(`Error previewing ${label}:`, error);
alert(`Failed to preview ${label}`);
} finally {
setPresetLoading(null);
}
};
const fetchData = async () => {
try {
const [combosRes, providersRes, settingsRes] = await Promise.all([
@@ -79,7 +172,7 @@ export default function CombosPage() {
const combosData = await combosRes.json();
const providersData = await providersRes.json();
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
// Only LLM combos here - webSearch/webFetch combos belong to media-providers/web
if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm"));
if (providersRes.ok) {
@@ -151,24 +244,80 @@ export default function CombosPage() {
}
};
const pruneStrategiesForNames = (names, base = comboStrategies) => {
const updated = { ...base };
for (const name of names) delete updated[name];
return updated;
};
const persistComboStrategies = async (updated) => {
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboStrategies: updated }),
});
setComboStrategies(updated);
};
const handleDelete = async (id) => {
const combo = combos.find((c) => c.id === id);
setConfirmState({
title: "Delete Combo",
message: "Delete this combo?",
message: combo ? `Delete combo "${combo.name}"?` : "Delete this combo?",
onConfirm: async () => {
setConfirmState(null);
setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter(c => c.id !== id));
if (combo?.name) {
await persistComboStrategies(pruneStrategiesForNames([combo.name]));
}
setCombos((prev) => prev.filter((c) => c.id !== id));
setSelectedIds((prev) => prev.filter((x) => x !== id));
}
setConfirmState(null);
} catch (error) {
console.log("Error deleting combo:", error);
setConfirmState((prev) => prev ? { ...prev, loading: false } : null);
}
}
});
};
const handleBulkDelete = () => {
if (selectedCombos.length === 0) return;
const count = selectedCombos.length;
setConfirmState({
title: "Delete Selected Combos",
message: `Delete ${count} selected combo${count === 1 ? "" : "s"}? This cannot be undone.`,
confirmText: "Delete",
variant: "danger",
onConfirm: async () => {
setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
setBulkBusy(true);
try {
const ids = selectedCombos.map((c) => c.id);
const names = selectedCombos.map((c) => c.name);
const results = await Promise.all(
ids.map((id) => fetch(`/api/combos/${id}`, { method: "DELETE" }))
);
const failed = results.filter((r) => !r.ok).length;
await persistComboStrategies(pruneStrategiesForNames(names));
setCombos((prev) => prev.filter((c) => !ids.includes(c.id)));
clearSelection();
setConfirmState(null);
if (failed > 0) alert(`Deleted with ${failed} failure${failed === 1 ? "" : "s"}.`);
} catch (error) {
console.log("Error bulk deleting combos:", error);
alert("Failed to delete selected combos");
setConfirmState((prev) => prev ? { ...prev, loading: false } : null);
} finally {
setBulkBusy(false);
}
},
});
};
// Merge a per-combo strategy patch into settings.comboStrategies.
// A "fallback" entry is only pruned when the global strategy is also fallback;
// otherwise it's kept so the combo explicitly overrides global round-robin/fusion.
@@ -187,13 +336,7 @@ export default function CombosPage() {
updated[comboName] = next;
}
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboStrategies: updated }),
});
setComboStrategies(updated);
await persistComboStrategies(updated);
} catch (error) {
console.log("Error updating combo strategy:", error);
}
@@ -251,27 +394,45 @@ export default function CombosPage() {
return tags.some((t) => activeTagFilters.has(t));
});
// Group by tag. A combo with [a, b] appears in both groups. Combos with no
// tags land in a synthetic "__untagged__" bucket. Order within each group
// follows the input (combos are already in `createdAt ASC` from the repo).
const groupedCombos = (() => {
const groups = new Map();
for (const c of visibleCombos) {
const tags = Array.isArray(c.tags) && c.tags.length > 0 ? c.tags : ["__untagged__"];
for (const t of tags) {
if (activeTagFilters.size > 0 && !activeTagFilters.has(t)) continue;
const arr = groups.get(t) || [];
arr.push(c);
groups.set(t, arr);
// Name -> models map so a combo model that is itself a combo can resolve caps.
const combosByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
const handleBulkSetStrategy = async (strategy) => {
if (selectedCombos.length === 0 || !strategy) return;
setBulkBusy(true);
try {
const updated = { ...comboStrategies };
for (const combo of selectedCombos) {
if (!strategy || strategy === "fallback") {
delete updated[combo.name];
} else {
updated[combo.name] = {
...(updated[combo.name] || {}),
fallbackStrategy: strategy,
};
}
}
await persistComboStrategies(updated);
} catch (error) {
console.log("Error bulk updating combo strategy:", error);
alert("Failed to update strategy for selected combos");
} finally {
setBulkBusy(false);
}
return groups;
})();
};
if (loading) {
return (
<div className="flex flex-col gap-6">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-sm text-text-muted mt-1">
Group models under one name, then pick a strategy per combo:
@@ -281,10 +442,40 @@ export default function CombosPage() {
<li><span className="font-medium text-text-main">Round Robin</span> — rotates models across requests to spread load</li>
<li><span className="font-medium text-text-main">Fusion</span> — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)</li>
</ul>
<p className="hidden text-xs text-text-muted mt-3 max-w-2xl">
<span className="font-medium text-text-main">Cursor / Claude Default</span> create combos named exactly like those clients&apos; model IDs (e.g. <code className="font-mono">composer-2.5</code>, <code className="font-mono">opus</code>), seeded with the matching <code className="font-mono">cu/…</code> or <code className="font-mono">cc/…</code> route so traffic can hit 9router without the prefix.
{" "}Note: Cursor IDE itself often blocks built-in Composer / Grok from Override OpenAI Base URL (&quot;model does not support custom API&quot;); add them via Cursor&apos;s <span className="font-medium text-text-main">Add Custom Model</span> using the combo name, or pick a model Cursor allows through the custom endpoint.
</p>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-stretch">
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
Create Combo
</Button>
<div className="hidden">
<Button
variant="secondary"
size="sm"
icon="edit_note"
loading={presetLoading === "cursor"}
disabled={!!presetLoading}
onClick={() => handleGeneratePresets("cursor")}
className="w-full whitespace-nowrap"
>
Cursor Default
</Button>
<Button
variant="secondary"
size="sm"
icon="smart_toy"
loading={presetLoading === "claude"}
disabled={!!presetLoading}
onClick={() => handleGeneratePresets("claude")}
className="w-full whitespace-nowrap"
>
Claude Default
</Button>
</div>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
Create Combo
</Button>
</div>
{/* Tag filter bar — chips toggle inclusion. OR semantics. */}
@@ -333,75 +524,108 @@ export default function CombosPage() {
</Button>
</div>
</Card>
) : activeTagFilters.size > 0 ? (
<div className="flex flex-col gap-6">
{[...groupedCombos.entries()].map(([tag, list]) => (
<section key={tag} className="flex flex-col gap-3">
<div className="flex items-center gap-2 px-1">
<span className="material-symbols-outlined text-[16px] text-primary">sell</span>
<h3 className="text-sm font-semibold">
{tag === "__untagged__" ? "Untagged" : tag}
</h3>
<span className="text-[11px] text-text-muted">({list.length})</span>
</div>
) : (
<div className="flex flex-col gap-3">
{/* Selection toolbar */}
<div className="flex min-w-0 flex-col gap-2 rounded-lg border border-black/5 bg-black/[0.015] px-3 py-2 dark:border-white/5 dark:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between">
<label className="flex cursor-pointer items-center gap-2 text-xs text-text-muted hover:text-primary select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected && !allSelected;
}}
onChange={toggleSelectAll}
className="h-3.5 w-3.5 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span>
{someSelected
? `${selectedIds.length} selected`
: `Select all (${combos.length})`}
</span>
</label>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{someSelected && (
<>
<div className="w-full min-w-[160px] sm:w-[200px]">
<Select
options={STRATEGY_OPTIONS}
value=""
placeholder="Set strategy…"
disabled={bulkBusy}
onChange={(e) => {
const v = e.target.value;
if (v) handleBulkSetStrategy(v);
}}
selectClassName="py-1.5 text-xs"
/>
</div>
<Button
size="sm"
variant="danger"
icon="delete"
disabled={bulkBusy}
loading={bulkBusy}
onClick={handleBulkDelete}
className="whitespace-nowrap"
>
Delete ({selectedIds.length})
</Button>
<Button
size="sm"
variant="ghost"
onClick={clearSelection}
disabled={bulkBusy}
>
Clear
</Button>
</>
)}
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(e) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
const oldIndex = combos.findIndex((c) => c.id === active.id);
const newIndex = combos.findIndex((c) => c.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
handleReorder(oldIndex, newIndex);
}}
>
<SortableContext items={combos.map((c) => c.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{list.map((combo) => (
<ComboCard
key={combo.id}
combo={combo}
getCaps={getCaps}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
globalStrategy={globalComboStrategy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
/>
{combos.map((combo) => (
<SortableComboCard key={combo.id} id={combo.id}>
{(handle) => (
<ComboCard
combo={combo}
getCaps={getCaps}
comboByName={combosByName}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
globalStrategy={globalComboStrategy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
dragHandle={handle}
selected={selectedIds.includes(combo.id)}
onToggleSelect={() => toggleSelect(combo.id)}
/>
)}
</SortableComboCard>
))}
</div>
</section>
))}
</SortableContext>
</DndContext>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(e) => {
const { active, over } = e;
if (!over || active.id === over.id) return;
const oldIndex = combos.findIndex((c) => c.id === active.id);
const newIndex = combos.findIndex((c) => c.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
handleReorder(oldIndex, newIndex);
}}
>
<SortableContext items={combos.map((c) => c.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<SortableComboCard key={combo.id} id={combo.id}>
{(handle) => (
<ComboCard
combo={combo}
getCaps={getCaps}
activeProviders={activeProviders}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
strategy={comboStrategies[combo.name] || {}}
globalStrategy={globalComboStrategy}
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
dragHandle={handle}
/>
)}
</SortableComboCard>
))}
</div>
</SortableContext>
</DndContext>
)}
<CapacityAdapterSection
capacityAdapter={capacityAdapter}
@@ -432,38 +656,54 @@ export default function CombosPage() {
/>
)}
{/* Confirm Delete Modal */}
{/* Confirm (delete / generate presets) */}
<ConfirmModal
isOpen={!!confirmState}
onClose={() => setConfirmState(null)}
onClose={() => !confirmState?.loading && setConfirmState(null)}
onConfirm={confirmState?.onConfirm}
title={confirmState?.title || "Confirm"}
message={confirmState?.message}
variant="danger"
confirmText={confirmState?.confirmText || "Confirm"}
variant={confirmState?.variant || "danger"}
loading={!!confirmState?.loading}
/>
</div>
);
}
const STRATEGY_OPTIONS = [
{ value: "fallback", label: "Fallback — try in order" },
{ value: "round-robin", label: "Round Robin — rotate" },
{ value: "fusion", label: "Fusion — panel + judge" },
];
const fmtK = (n) => {
if (!n) return "?";
if (n >= 1000000) {
const m = n / 1000000;
return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;
}
return `${Math.round(n / 1000)}k`;
};
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy, dragHandle = null }) {
function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy, dragHandle = null, selected = false, onToggleSelect }) {
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
// Show the effective strategy: per-combo override first, then the global
// default (combos without an entry fall through to settings.comboStrategy).
const current = strategy.fallbackStrategy || globalStrategy || "fallback";
const judge = strategy.judgeModel || "";
const isFusion = current === "fusion";
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
return (
<Card padding="sm" className="group">
<Card padding="sm" className={`group ${selected ? "ring-1 ring-primary/40 bg-primary/[0.03]" : ""}`}>
<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-1 items-start gap-3 sm:items-center">
{dragHandle}
<label className="flex shrink-0 items-center pt-1 sm:pt-0 cursor-pointer" title="Select combo">
<input
type="checkbox"
checked={selected}
onChange={onToggleSelect}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
aria-label={`Select ${combo.name}`}
/>
</label>
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
</div>
@@ -476,7 +716,11 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
combo.models.slice(0, 3).map((model, index) => (
<code key={index} className="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)} />
<CapacityBadges caps={
comboByName[model]
? aggregateComboCapabilities(comboByName[model], comboByName)
: getCaps?.(model)
} />
</code>
))
)}
@@ -495,6 +739,13 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
))}
</div>
)}
{comboCaps && (
<div className="mt-1 flex items-center gap-2 text-[10px] text-text-muted">
<span>ctx {fmtK(comboCaps.contextWindow)}</span>
<span className="opacity-40">·</span>
<span>max {fmtK(comboCaps.maxOutput)}</span>
</div>
)}
{/* Fusion: judge picker (Auto = first model) */}
{isFusion && (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
@@ -618,10 +869,6 @@ function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, ge
<p className="text-xs text-text-muted mt-0.5">
Your model can&apos;t read image/audio? Auto-switches to a model in the pool below.
</p>
<ul className="mt-1.5 text-[11px] text-text-muted flex flex-col gap-0.5">
<li><span className="font-medium text-text-main">Vision</span> — images (png, jpg, webp, …)</li>
<li><span className="font-medium text-text-main">Audio</span> — audio input</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-4">

View File

@@ -9,8 +9,14 @@ import { Row, KIND_EXAMPLE_CONFIG } from "./exampleShared";
const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png";
const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png";
// HuggingFace router edit models need a source image; reuse the public dog sample so
// the card is runnable as-is. The router derives it from inputs, not from the Hub host.
const HUGGINGFACE_TEST_IMAGE_URL = CLOUDFLARE_TEST_IMAGE_URL;
function getImageEditDefaults(providerId, modelId) {
if (providerId === "huggingface") {
return { image: HUGGINGFACE_TEST_IMAGE_URL };
}
if (providerId !== "cloudflare-ai") return {};
if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
return { image: CLOUDFLARE_TEST_IMAGE_URL };
@@ -38,8 +44,8 @@ export function GenericExampleCard({ providerId, kind }) {
// Get models for this kind (e.g., type="image")
const kindModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === kind);
// Kinds that need a model identifier in the request (image/video/music)
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
// Kinds that need a model identifier in the request (image/video/music/systemone)
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText", "systemone"]);
const needsModel = KIND_NEEDS_MODEL.has(kind);
const allowManualModel = needsModel && kindModels.length === 0;
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
@@ -48,6 +54,7 @@ export function GenericExampleCard({ providerId, kind }) {
const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
const [input, setInput] = useState(safeExConfig.defaultInput || "");
const [question, setQuestion] = useState("Does this request require urgent attention?");
const [refImage, setRefImage] = useState("");
const [maskImage, setMaskImage] = useState("");
const [extraValues, setExtraValues] = useState(() =>
@@ -111,11 +118,20 @@ export function GenericExampleCard({ providerId, kind }) {
acc[k] = v;
return acc;
}, {});
const systemoneQuestions = kind === "systemone" ? {
questions: {
is_urgent: {
type: "noul",
instructions: question.trim() || "Does this request require urgent attention?",
},
},
} : {};
const requestBody = {
model: modelFull,
[exConfig.bodyKey]: input,
...exConfig.extraBody,
...extraBodyFromFields,
...systemoneQuestions,
...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
};
@@ -322,6 +338,29 @@ export function GenericExampleCard({ providerId, kind }) {
</div>
</Row>
{/* Question for System One */}
{kind === "systemone" && (
<Row label="Question">
<div className="relative">
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Enter evaluation question or criteria"
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
{question && (
<button
type="button"
onClick={() => setQuestion("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
)}
{/* Reference image (only for edit-capable image models) */}
{supportsEdit && (
<Row label="Ref Image (URL)">

View File

@@ -75,4 +75,19 @@ export const KIND_EXAMPLE_CONFIG = {
bodyKey: "prompt",
defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`,
},
systemone: {
inputLabel: "State",
inputPlaceholder: "Situation, support ticket, or text to evaluate",
defaultInput: "My payments have failed for three days and I am losing sales. Please help now.",
bodyKey: "state",
extraBody: {
questions: {
is_urgent: {
type: "noul",
instructions: "Does this request require urgent attention?",
},
},
},
defaultResponse: `{\n "model": "jev-1.13",\n "answers": {\n "is_urgent": { "type": "noul", "noul": 0.99 }\n },\n "usage": { "input_tokens": 312, "output_tokens": 48 }\n}`,
},
};

View File

@@ -171,14 +171,15 @@ export default function MediaProviderDetailPage() {
/>
)}
{/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, searchViaChat */}
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.searchViaChat) && (
{/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, systemoneConfig, searchViaChat */}
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.systemoneConfig || provider.searchViaChat) && (
<ProviderInfoCard
config={
kind === "webFetch" ? provider.fetchConfig
: kind === "tts" ? provider.ttsConfig
: kind === "stt" ? provider.sttConfig
: kind === "embedding" ? provider.embeddingConfig
: kind === "systemone" ? provider.systemoneConfig
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, pricingUrl: provider.searchViaChat?.pricingUrl, freeTier: provider.searchViaChat?.freeTier }
}
provider={provider}

View File

@@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
const isXaiApiKey = provider === "xai" && !isCookie;
const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" ? "Personal Access Token (PAT)" : "API Key";
const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" || provider === "qoder-cn" ? "Personal Access Token (PAT)" : "API Key";
const credentialPlaceholder = isCookie
? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...")
: (isXaiApiKey ? "xai-..." : provider === "qoder" ? "pt-..." : "");
: (isXaiApiKey ? "xai-..." : provider === "qoder" || provider === "qoder-cn" ? "pt-..." : "");
const isAzure = provider === "azure";
const isCloudflareAi = provider === "cloudflare-ai";
@@ -44,7 +44,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [saving, setSaving] = useState(false);
const bulkPlaceholder = isCloudflareAi
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
: provider === "qoder"
: provider === "qoder" || provider === "qoder-cn"
? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named`
: BULK_PLACEHOLDER;
@@ -200,7 +200,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
<p className="text-xs text-text-muted">
{isCloudflareAi
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
: provider === "qoder"
: provider === "qoder" || provider === "qoder-cn"
? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
}

View File

@@ -185,7 +185,7 @@ export default function ProviderDetailPage() {
const apiKeyConnectionLabel =
providerId === "xai" ? "xAI API Key"
: providerId === "kimi" ? "Kimi API Key"
: providerId === "qoder" ? "PAT"
: (providerId === "qoder" || providerId === "qoder-cn") ? "PAT"
: "API Key";
const providerStorageAlias = isCompatible ? providerId : providerAlias;
// Capability store lives server-side; this bundle cannot read it, so the
@@ -701,8 +701,9 @@ export default function ProviderDetailPage() {
const modelId = model.id || model.name;
if (!modelId) continue;
// Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix
const cleanModelId = modelId.replace(/^qoder\//, "");
// Qoder model ID format may be "qoder/auto", "qoder-cn/auto" or "auto",
// need to remove the provider prefix before storing.
const cleanModelId = modelId.replace(/^(qoder-cn|qoder)\//, "");
const alreadyExists = customModels.some(
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm"
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`);
@@ -1676,8 +1677,8 @@ export default function ProviderDetailPage() {
Add Model
</button>
{/* Import Qoder models button — only show for qoder provider */}
{providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && (
{/* Import Qoder models button — only show for qoder/qoder-cn provider */}
{(providerId === "qoder" || providerId === "qoder-cn") && connections.some((conn) => conn.isActive !== false) && (
<button
onClick={handleImportQoderModels}
disabled={importingQoderModels}

View File

@@ -9,25 +9,25 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
export default function OverviewCards({ stats }) {
return (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Total Requests</span>
<span className="w-full truncate text-lg font-bold xl:text-xl" title={fmt(stats.totalRequests)}>{fmt(stats.totalRequests)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Total Input Tokens</span>
<span className="w-full truncate text-lg font-bold text-primary xl:text-xl" title={fmt(stats.totalPromptTokens)}>{fmt(stats.totalPromptTokens)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
<span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span>
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Cached Tokens</span>
<span className="w-full truncate text-lg font-bold text-info xl:text-xl" title={fmt(stats.totalCachedTokens)}>{fmt(stats.totalCachedTokens)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Output Tokens</span>
<span className="w-full truncate text-lg font-bold text-success xl:text-xl" title={fmt(stats.totalCompletionTokens)}>{fmt(stats.totalCompletionTokens)}</span>
</Card>
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Est. Cost</span>
<span className="truncate text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Est. Cost</span>
<span className="w-full truncate text-lg font-bold text-warning xl:text-xl" title={`~${fmtCost(stats.totalCost)}`}>~{fmtCost(stats.totalCost)}</span>
<span className="text-[10px] text-text-muted">Estimated, not actual billing</span>
</Card>
</div>

View File

@@ -0,0 +1,107 @@
"use client";
import { useState, useMemo } from "react";
import PropTypes from "prop-types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import Card from "@/shared/components/Card";
const COLORS = ["#6366f1", "#14b8a6", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#10b981", "#f97316"];
const fmtTokens = (n) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n || 0);
};
export default function ProviderBarChart({ byProvider }) {
const [viewMode, setViewMode] = useState("tokens");
const chartData = useMemo(() => {
if (!byProvider) return [];
return Object.entries(byProvider)
.map(([id, data]) => ({
name: id,
tokens: (data.promptTokens || 0) + (data.completionTokens || 0),
requests: data.requests || 0,
}))
.filter((d) => d[viewMode] > 0)
.sort((a, b) => b[viewMode] - a[viewMode]);
}, [byProvider, viewMode]);
const fmt = viewMode === "tokens" ? fmtTokens : String;
const label = viewMode === "tokens" ? "Tokens" : "Requests";
return (
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-text-muted uppercase tracking-wide">By Provider</span>
<div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
<button
onClick={() => setViewMode("tokens")}
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("requests")}
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "requests" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Requests
</button>
</div>
</div>
{!chartData.length ? (
<div className="h-44 flex items-center justify-center text-text-muted text-sm">No provider usage yet</div>
) : (
<ResponsiveContainer width="100%" height={180}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.6 }}
tickLine={false}
axisLine={false}
interval={0}
tickFormatter={(v) => v.length > 10 ? v.slice(0, 10) + "…" : v}
/>
<YAxis
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
tickFormatter={fmt}
width={44}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-bg)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value) => [fmt(value), label]}
/>
<Bar dataKey={viewMode} radius={[4, 4, 0, 0]}>
{chartData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.85} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Card>
);
}
ProviderBarChart.propTypes = {
byProvider: PropTypes.object,
};

View File

@@ -45,6 +45,7 @@ export default function ProviderLimitCard({
codex: "#10A37F",
kiro: "#FF9900",
qoder: "#EC4899",
"qoder-cn": "#EC4899",
claude: "#D97757",
};
return colors[provider?.toLowerCase()] || "#6B7280";

View File

@@ -377,51 +377,112 @@ export function parseQuotaData(provider, data) {
if (data.quotas) {
const entries = Object.entries(data.quotas);
const weeklyKeys = new Set(["gemini_weekly", "claude_gpt_weekly"]);
const sessionKeys = new Set(["gemini_session", "claude_gpt_session"]);
const summaryKeys = new Set([...weeklyKeys, ...sessionKeys]);
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
const imageModels = entries.filter(([k]) => k.includes("image"));
const weeklyModels = entries.filter(([k]) => weeklyKeys.has(k));
const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image") && !weeklyKeys.has(k));
const summaryModels = entries.filter(([k]) => summaryKeys.has(k));
const otherModels = entries.filter(([k]) => !k.startsWith("gemini-") && !k.startsWith("claude-") && !k.includes("image") && !summaryKeys.has(k));
if (geminiModels.length > 0) {
// Summary keys from retrieveUserQuotaSummary
const hasGeminiWeekly = Boolean(data.quotas.gemini_weekly);
const hasGeminiSession = Boolean(data.quotas.gemini_session);
const hasClaudeWeekly = Boolean(data.quotas.claude_gpt_weekly);
const hasClaudeSession = Boolean(data.quotas.claude_gpt_session);
// 1. Gemini Family:
if (hasGeminiSession) {
summaryModels.filter(([k]) => k === "gemini_session").forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
} else if (geminiModels.length > 0) {
const rep = geminiModels.reduce((min, cur) =>
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
)[1];
normalizedQuotas.push({
name: "Gemini (Flash / Pro)",
modelKey: "gemini",
used: rep.used || 0,
total: rep.total || 0,
resetAt: rep.resetAt || null,
remainingPercentage: rep.remainingPercentage,
// Only show synthesized Gemini row if its resetAt differs from weekly (i.e. it represents a separate 5h window)
const weeklyResetAt = data.quotas.gemini_weekly?.resetAt;
const isDuplicateOfWeekly = hasGeminiWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0;
if (!isDuplicateOfWeekly) {
normalizedQuotas.push({
name: "Gemini (Flash / Pro)",
modelKey: "gemini",
used: rep.used || 0,
total: rep.total || 0,
resetAt: rep.resetAt || null,
remainingPercentage: rep.remainingPercentage,
});
}
}
// Show Gemini weekly row if present
if (hasGeminiWeekly) {
summaryModels.filter(([k]) => k === "gemini_weekly").forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
if (claudeModels.length > 0) {
// 2. Claude & GPT Family:
if (hasClaudeSession) {
summaryModels.filter(([k]) => k === "claude_gpt_session").forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
} else if (claudeModels.length > 0) {
const rep = claudeModels.reduce((min, cur) =>
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
)[1];
normalizedQuotas.push({
name: "Claude (Sonnet / Opus)",
modelKey: "claude",
used: rep.used || 0,
total: rep.total || 0,
resetAt: rep.resetAt || null,
remainingPercentage: rep.remainingPercentage,
const weeklyResetAt = data.quotas.claude_gpt_weekly?.resetAt;
const isDuplicateOfWeekly = hasClaudeWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0;
if (!isDuplicateOfWeekly) {
normalizedQuotas.push({
name: "Claude (Sonnet / Opus)",
modelKey: "claude",
used: rep.used || 0,
total: rep.total || 0,
resetAt: rep.resetAt || null,
remainingPercentage: rep.remainingPercentage,
});
}
}
// Show Claude & GPT weekly row if present
if (hasClaudeWeekly) {
summaryModels.filter(([k]) => k === "claude_gpt_weekly").forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
weeklyModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
// 3. Standalone Image Generation Models (unique usage)
imageModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
@@ -433,16 +494,23 @@ export function parseQuotaData(provider, data) {
});
});
otherModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
// 4. Other models:
// In Antigravity, GPT-OSS is explicitly documented by Google as part of the "Claude and GPT models" group:
// ("Models within this group: Claude Opus, Claude Sonnet, GPT-OSS").
// When summary quotas (claude_gpt_session / claude_gpt_weekly) are present, GPT-OSS is already represented
// by the "Claude & GPT" family rows. We only include otherModels if no summary exists for that pool.
if (!hasClaudeWeekly && !hasClaudeSession) {
otherModels.forEach(([modelKey, quota]) => {
normalizedQuotas.push({
name: quota.displayName || modelKey,
modelKey,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
});
}
}
break;
@@ -483,6 +551,7 @@ export function parseQuotaData(provider, data) {
break;
case "qoder":
case "qoder-cn":
// Qoder ships a `user` quota and (optionally) an `organization`
// quota, both with same shape: {total, used, remaining, unit, resetAt}.
// Skip an organization bucket when its total is 0 — most personal
@@ -632,7 +701,7 @@ export function parseQuotaData(provider, data) {
break;
case "ollama":
// Session (5h) / Weekly (7d) usage % from ollama.com/api/usage.
// Session (5h) / Weekly (7d) / Monthly usage % from ollama.com/api/usage.
// remainingPercentage only — no absolute remaining (UI treats remaining as %).
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
@@ -762,10 +831,10 @@ export function parseQuotaData(provider, data) {
// Use modelKey for antigravity (mapped to family anchor), otherwise use name
let keyA = a.modelKey || a.name;
let keyB = b.modelKey || b.name;
if (keyA === "gemini") keyA = "gemini-3.8-flash-high";
if (keyA === "claude") keyA = "claude-sonnet-4-6";
if (keyB === "gemini") keyB = "gemini-3.8-flash-high";
if (keyB === "claude") keyB = "claude-sonnet-4-6";
if (keyA === "gemini" || keyA === "gemini_session") keyA = "gemini-3.8-flash-high";
if (keyA === "claude" || keyA === "claude_gpt_session") keyA = "claude-sonnet-4-6";
if (keyB === "gemini" || keyB === "gemini_session") keyB = "gemini-3.8-flash-high";
if (keyB === "claude" || keyB === "claude_gpt_session") keyB = "claude-sonnet-4-6";
const orderA = orderMap.get(keyA) ?? 999;
const orderB = orderMap.get(keyB) ?? 999;
return orderA - orderB;

View File

@@ -0,0 +1,114 @@
"use client";
import { useState, useMemo } from "react";
import PropTypes from "prop-types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import Card from "@/shared/components/Card";
const COLORS = ["#6366f1", "#14b8a6", "#f59e0b", "#ef4444", "#8b5cf6"];
const fmtTokens = (n) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n || 0);
};
const truncate = (s, max = 22) => (s && s.length > max ? s.slice(0, max) + "…" : s || "");
export default function TopModelsChart({ byModel }) {
const [viewMode, setViewMode] = useState("tokens");
const chartData = useMemo(() => {
if (!byModel) return [];
return Object.values(byModel)
.map((data) => ({
name: truncate(data.rawModel || "Unknown"),
tokens: (data.promptTokens || 0) + (data.completionTokens || 0),
requests: data.requests || 0,
}))
.filter((d) => d[viewMode] > 0)
.sort((a, b) => b[viewMode] - a[viewMode])
.slice(0, 5);
}, [byModel, viewMode]);
const fmt = viewMode === "tokens" ? fmtTokens : String;
const label = viewMode === "tokens" ? "Tokens" : "Requests";
return (
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-text-muted uppercase tracking-wide">Top Models</span>
<div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
<button
onClick={() => setViewMode("tokens")}
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("requests")}
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "requests" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Requests
</button>
</div>
</div>
{!chartData.length ? (
<div className="h-44 flex items-center justify-center text-text-muted text-sm">No model usage yet</div>
) : (
<ResponsiveContainer width="100%" height={180}>
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 4, right: 40, left: 4, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
tickFormatter={fmt}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.7 }}
tickLine={false}
axisLine={false}
width={90}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-bg)",
border: "1px solid var(--color-border)",
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value) => [fmt(value), label]}
/>
<Bar dataKey={viewMode} radius={[0, 4, 4, 0]}>
{chartData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.85} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</Card>
);
}
TopModelsChart.propTypes = {
byModel: PropTypes.object,
};

View File

@@ -10,7 +10,6 @@ import {
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import Card from "@/shared/components/Card";
@@ -21,6 +20,19 @@ const fmtTokens = (n) => {
};
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
const fmtRequests = (n) => String(n || 0);
const VIEW_MODES = [
{ value: "tokens", label: "Tokens" },
{ value: "requests", label: "Requests" },
{ value: "cost", label: "Cost" },
];
const VIEW_CONFIG = {
tokens: { dataKey: "tokens", color: "#6366f1", gradId: "gradTokens", formatter: fmtTokens, label: "Tokens" },
requests: { dataKey: "requests", color: "#14b8a6", gradId: "gradRequests", formatter: fmtRequests, label: "Requests" },
cost: { dataKey: "cost", color: "#f59e0b", gradId: "gradCost", formatter: fmtCost, label: "Cost" },
};
export default function UsageChart({ period = "7d" }) {
const [data, setData] = useState([]);
@@ -46,23 +58,24 @@ export default function UsageChart({ period = "7d" }) {
fetchData();
}, [fetchData]);
const hasData = data.some((d) => d.tokens > 0 || d.cost > 0);
const cfg = VIEW_CONFIG[viewMode];
const hasData = data.some((d) => (d[cfg.dataKey] || 0) > 0);
return (
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div className="grid w-full grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start">
<button
onClick={() => setViewMode("tokens")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("cost")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cost" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
Cost
</button>
<div
className="grid w-full items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start"
style={{ gridTemplateColumns: `repeat(${VIEW_MODES.length}, minmax(0, 1fr))` }}
>
{VIEW_MODES.map((m) => (
<button
key={m.value}
onClick={() => setViewMode(m.value)}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === m.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
>
{m.label}
</button>
))}
</div>
{loading ? (
@@ -77,6 +90,10 @@ export default function UsageChart({ period = "7d" }) {
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.25} />
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradRequests" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#14b8a6" stopOpacity={0.25} />
<stop offset="95%" stopColor="#14b8a6" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
@@ -94,7 +111,7 @@ export default function UsageChart({ period = "7d" }) {
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
tickLine={false}
axisLine={false}
tickFormatter={viewMode === "tokens" ? fmtTokens : fmtCost}
tickFormatter={cfg.formatter}
width={50}
/>
<Tooltip
@@ -104,31 +121,17 @@ export default function UsageChart({ period = "7d" }) {
borderRadius: "8px",
fontSize: "12px",
}}
formatter={(value, name) =>
name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"]
}
formatter={(value) => [cfg.formatter(value), cfg.label]}
/>
<Area
type="monotone"
dataKey={cfg.dataKey}
stroke={cfg.color}
strokeWidth={2}
fill={`url(#${cfg.gradId})`}
dot={false}
activeDot={{ r: 4 }}
/>
{viewMode === "tokens" ? (
<Area
type="monotone"
dataKey="tokens"
stroke="#6366f1"
strokeWidth={2}
fill="url(#gradTokens)"
dot={false}
activeDot={{ r: 4 }}
/>
) : (
<Area
type="monotone"
dataKey="cost"
stroke="#f59e0b"
strokeWidth={2}
fill="url(#gradCost)"
dot={false}
activeDot={{ r: 4 }}
/>
)}
</AreaChart>
</ResponsiveContainer>
)}

View File

@@ -11,6 +11,7 @@ const PERIODS = [
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "60d", label: "60D" },
{ value: "all", label: "All" },
];
export default function UsagePage() {

View File

@@ -14,6 +14,12 @@ import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route";
import { GET as jcodeGet } from "../jcode-settings/route";
import { GET as grokBuildGet } from "../grok-build-settings/route";
import { GET as devinGet } from "../devin-settings/route";
import { GET as piGet } from "../pi-settings/route";
import { GET as ompGet } from "../omp-settings/route";
import { GET as crushGet } from "../crush-settings/route";
import { GET as forgeGet } from "../forge-settings/route";
import { GET as smeltGet } from "../smelt-settings/route";
import { GET as codewhaleGet } from "../codewhale-settings/route";
const STATUS_GETTERS = {
claude: claudeGet,
@@ -29,6 +35,12 @@ const STATUS_GETTERS = {
jcode: jcodeGet,
"grok-build": grokBuildGet,
devin: devinGet,
pi: piGet,
omp: ompGet,
crush: crushGet,
forge: forgeGet,
smelt: smeltGet,
codewhale: codewhaleGet,
};
// Batch endpoint: gather all CLI tool statuses in one round-trip

View File

@@ -0,0 +1,142 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { parseTOML, stringifyTOML } from "confbox";
const execAsync = promisify(exec);
const getCodewhaleDir = () => path.join(os.homedir(), ".codewhale");
const getCodewhaleConfigPath = () => path.join(getCodewhaleDir(), "config.toml");
const checkCodewhaleInstalled = async () => {
const isWindows = os.platform() === "win32";
try {
const command = isWindows ? "where codewhale" : "which codewhale";
await execAsync(command, { windowsHide: true });
return true;
} catch {
try {
await fs.access(getCodewhaleConfigPath());
return true;
} catch {
return false;
}
}
};
const has9RouterConfig = (content) => {
if (!content) return false;
return content.includes("managed by 9Router") || content.includes("localhost:20128");
};
const readConfig = async () => {
try {
return await fs.readFile(getCodewhaleConfigPath(), "utf-8");
} catch {
return null;
}
};
export async function GET() {
try {
const installed = await checkCodewhaleInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "CodeWhale CLI is not installed",
});
}
const content = await readConfig();
let config = null;
try {
if (content) config = parseTOML(content);
} catch {}
return NextResponse.json({
installed: true,
config,
has9Router: has9RouterConfig(content),
configPath: getCodewhaleConfigPath(),
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
const { baseUrl, apiKey, model } = rawBody || {};
if (!baseUrl) {
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
}
const configPath = getCodewhaleConfigPath();
await fs.mkdir(getCodewhaleDir(), { recursive: true });
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = parseTOML(raw);
} catch {}
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
existing.openai = {
base_url: normalizedBaseUrl,
api_key: apiKey || "sk_9router",
model: model || "provider/model-id",
};
const header = "# CodeWhale config — managed by 9Router\n\n";
const content = header + stringifyTOML(existing);
await fs.writeFile(configPath, content, "utf-8");
return NextResponse.json({
success: true,
message: "CodeWhale settings applied successfully!",
configPath,
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function DELETE() {
try {
const configPath = getCodewhaleConfigPath();
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = parseTOML(raw);
} catch {
return NextResponse.json({ success: true, message: "No config file to reset" });
}
delete existing.openai;
if (Object.keys(existing).length === 0) {
await fs.rm(configPath, { force: true });
} else {
await fs.writeFile(configPath, stringifyTOML(existing), "utf-8");
}
return NextResponse.json({ success: true, message: "9Router removed from CodeWhale" });
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}

View File

@@ -0,0 +1,154 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const getCrushConfigPath = () => {
const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
return path.join(configDir, "crush", "crush.json");
};
const getCrushDir = () => path.dirname(getCrushConfigPath());
const checkCrushInstalled = async () => {
const isWindows = os.platform() === "win32";
try {
const command = isWindows ? "where crush" : "which crush";
await execAsync(command, { windowsHide: true });
return true;
} catch {
try {
await fs.access(getCrushConfigPath());
return true;
} catch {
return false;
}
}
};
const has9RouterConfig = (settings) => {
if (!settings || !settings.providers) return false;
const p = settings.providers["9router"];
if (p && p.base_url) return true;
for (const prov of Object.values(settings.providers)) {
if (prov.base_url && prov.base_url.includes("20128")) return true;
}
return false;
};
const readConfig = async () => {
try {
const content = await fs.readFile(getCrushConfigPath(), "utf-8");
return JSON.parse(content);
} catch {
return null;
}
};
export async function GET() {
try {
const installed = await checkCrushInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "Crush CLI is not installed",
});
}
const config = await readConfig();
return NextResponse.json({
installed: true,
config,
has9Router: has9RouterConfig(config),
configPath: getCrushConfigPath(),
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
const { baseUrl, apiKey, model } = rawBody || {};
if (!baseUrl) {
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
}
const configPath = getCrushConfigPath();
await fs.mkdir(getCrushDir(), { recursive: true });
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {
/* No existing config */
}
if (!existing.providers) existing.providers = {};
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const modelId = model || "provider/model-id";
existing.providers["9router"] = {
type: "openai-compat",
base_url: normalizedBaseUrl,
api_key: apiKey || "sk_9router",
models: [
{
id: modelId,
name: modelId,
context_window: 128000,
},
],
};
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
return NextResponse.json({
success: true,
message: "Crush settings applied successfully!",
configPath,
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function DELETE() {
try {
const configPath = getCrushConfigPath();
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {
return NextResponse.json({ success: true, message: "No config file to reset" });
}
if (existing.providers && existing.providers["9router"]) {
delete existing.providers["9router"];
if (Object.keys(existing.providers).length === 0) delete existing.providers;
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
}
return NextResponse.json({ success: true, message: "9Router removed from Crush" });
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}

View File

@@ -0,0 +1,142 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { parseTOML, stringifyTOML } from "confbox";
const execAsync = promisify(exec);
const getForgeDir = () => path.join(os.homedir(), ".forge");
const getForgeConfigPath = () => path.join(getForgeDir(), "config.toml");
const checkForgeInstalled = async () => {
const isWindows = os.platform() === "win32";
try {
const command = isWindows ? "where forge" : "which forge";
await execAsync(command, { windowsHide: true });
return true;
} catch {
try {
await fs.access(getForgeConfigPath());
return true;
} catch {
return false;
}
}
};
const has9RouterConfig = (content) => {
if (!content) return false;
return content.includes("managed by 9Router") || content.includes("localhost:20128");
};
const readConfig = async () => {
try {
return await fs.readFile(getForgeConfigPath(), "utf-8");
} catch {
return null;
}
};
export async function GET() {
try {
const installed = await checkForgeInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "ForgeCode CLI is not installed",
});
}
const content = await readConfig();
let config = null;
try {
if (content) config = parseTOML(content);
} catch {}
return NextResponse.json({
installed: true,
config,
has9Router: has9RouterConfig(content),
configPath: getForgeConfigPath(),
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
const { baseUrl, apiKey, model } = rawBody || {};
if (!baseUrl) {
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
}
const configPath = getForgeConfigPath();
await fs.mkdir(getForgeDir(), { recursive: true });
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = parseTOML(raw);
} catch {}
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
existing.openai = {
api_key: apiKey || "sk_9router",
base_url: normalizedBaseUrl,
model: model || "provider/model-id",
};
const header = "# Forge config — managed by 9Router\n\n";
const content = header + stringifyTOML(existing);
await fs.writeFile(configPath, content, "utf-8");
return NextResponse.json({
success: true,
message: "ForgeCode settings applied successfully!",
configPath,
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function DELETE() {
try {
const configPath = getForgeConfigPath();
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = parseTOML(raw);
} catch {
return NextResponse.json({ success: true, message: "No config file to reset" });
}
delete existing.openai;
if (Object.keys(existing).length === 0) {
await fs.rm(configPath, { force: true });
} else {
await fs.writeFile(configPath, stringifyTOML(existing), "utf-8");
}
return NextResponse.json({ success: true, message: "9Router removed from ForgeCode" });
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}

View File

@@ -0,0 +1,179 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const PROVIDER_ID = "9router";
const getOmpDir = () => path.join(os.homedir(), ".omp", "agent");
const getOmpDbPath = () => path.join(getOmpDir(), "agent.db");
const getOmpModelsYmlPath = () => path.join(getOmpDir(), "models.yml");
const checkOmpInstalled = async () => {
const isWindows = os.platform() === "win32";
try {
const command = isWindows ? "where omp" : "which omp";
await execAsync(command, { windowsHide: true });
return true;
} catch {
try {
await fs.access(getOmpDbPath());
return true;
} catch {
try {
await fs.access(getOmpModelsYmlPath());
return true;
} catch {
return false;
}
}
}
};
const readModelsYml = async () => {
try {
return await fs.readFile(getOmpModelsYmlPath(), "utf-8");
} catch {
return "";
}
};
const has9RouterInYml = (content) => {
if (!content) return false;
return content.includes("9router:") || content.includes("localhost:20128");
};
// Build standard 9Router provider block for models.yml
const buildOmpProviderYaml = (baseUrl, apiKey) => {
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const key = apiKey || "sk_9router";
return ` ${PROVIDER_ID}:
baseUrl: ${normalizedBaseUrl}
apiKey: ${key}
api: openai-completions
authHeader: true
disableStrictTools: true
discovery:
type: proxy`;
};
export async function GET() {
try {
const installed = await checkOmpInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "Oh My Pi is not installed",
});
}
const ymlContent = await readModelsYml();
const has9Router = has9RouterInYml(ymlContent);
return NextResponse.json({
installed: true,
has9Router,
configPath: getOmpModelsYmlPath(),
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
const { baseUrl, apiKey } = rawBody || {};
if (!baseUrl) {
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
}
await fs.mkdir(getOmpDir(), { recursive: true });
let ymlContent = await readModelsYml();
const providerBlock = buildOmpProviderYaml(baseUrl, apiKey);
// Remove existing 9router provider if present
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
ymlContent = ymlContent.replace(regex, "");
if (!ymlContent.trim()) {
ymlContent = `providers:\n${providerBlock}\n`;
} else if (ymlContent.includes("providers:")) {
ymlContent = ymlContent.replace(/providers:/, `providers:\n${providerBlock}`);
} else {
ymlContent = `${ymlContent.trim()}\n\nproviders:\n${providerBlock}\n`;
}
await fs.writeFile(getOmpModelsYmlPath(), ymlContent, "utf-8");
// Best-effort update to agent.db if better-sqlite3 or node:sqlite is present
try {
let Database;
try {
const mod = await import("better-sqlite3");
Database = mod.default || mod;
} catch {
// fallback ignored
}
if (Database) {
const dbPath = getOmpDbPath();
const db = new Database(dbPath);
db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(PROVIDER_ID);
db.prepare(
"INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?)"
).run(
PROVIDER_ID,
"api_key",
JSON.stringify({ apiKey: apiKey || "sk_9router", baseUrl }),
Math.floor(Date.now() / 1000),
Math.floor(Date.now() / 1000)
);
db.close();
}
} catch {
// Non-critical: models.yml is primary
}
return NextResponse.json({
success: true,
message: "Oh My Pi settings applied! Run 'omp' and all 9Router models appear under 9router in /model.",
configPath: getOmpModelsYmlPath(),
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function DELETE() {
try {
let ymlContent = await readModelsYml();
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
ymlContent = ymlContent.replace(regex, "");
if (ymlContent.trim() === "providers:") {
await fs.rm(getOmpModelsYmlPath(), { force: true });
} else {
await fs.writeFile(getOmpModelsYmlPath(), ymlContent, "utf-8");
}
return NextResponse.json({
success: true,
message: "9Router removed from Oh My Pi",
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}

View File

@@ -0,0 +1,187 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const getPiModelsJsonPath = () => {
const agentPath = path.join(os.homedir(), ".pi", "agent", "models.json");
return agentPath;
};
const getPiDir = () => path.dirname(getPiModelsJsonPath());
const checkPiInstalled = async () => {
const isWindows = os.platform() === "win32";
try {
const command = isWindows ? "where pi" : "which pi";
await execAsync(command, { windowsHide: true });
return true;
} catch {
try {
await fs.access(getPiModelsJsonPath());
return true;
} catch {
try {
await fs.access(path.join(os.homedir(), ".pi", "models.json"));
return true;
} catch {
return false;
}
}
}
};
const has9RouterConfig = (settings) => {
if (!settings || !settings.providers) return false;
const p = settings.providers["9router"];
if (p && p.baseUrl) return true;
for (const prov of Object.values(settings.providers)) {
if (prov.baseUrl && prov.baseUrl.includes("20128")) return true;
}
return false;
};
const resolveModelsJsonPath = async () => {
const agentPath = path.join(os.homedir(), ".pi", "agent", "models.json");
const rootPath = path.join(os.homedir(), ".pi", "models.json");
try {
await fs.access(agentPath);
return agentPath;
} catch {
try {
await fs.access(rootPath);
return rootPath;
} catch {
return agentPath;
}
}
};
const readConfig = async () => {
try {
const targetPath = await resolveModelsJsonPath();
const content = await fs.readFile(targetPath, "utf-8");
return JSON.parse(content);
} catch {
return null;
}
};
export async function GET() {
try {
const installed = await checkPiInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "Pi CLI is not installed",
});
}
const config = await readConfig();
const configPath = await resolveModelsJsonPath();
return NextResponse.json({
installed: true,
config,
has9Router: has9RouterConfig(config),
configPath,
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
const { baseUrl, apiKey, model } = rawBody || {};
if (!baseUrl) {
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
}
const configPath = await resolveModelsJsonPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {
/* No existing config */
}
if (!existing.providers) existing.providers = {};
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
let modelList = [];
if (Array.isArray(rawBody.models) && rawBody.models.length > 0) {
modelList = rawBody.models.map((m) => {
if (typeof m === "string") {
return { id: m, name: m, contextWindow: 128000, maxTokens: 16384 };
}
return {
id: m.id || "provider/model-id",
name: m.name || m.id || "provider/model-id",
contextWindow: m.contextWindow || 128000,
maxTokens: m.maxTokens || 16384,
};
});
} else {
const modelId = model || "provider/model-id";
modelList = [{ id: modelId, name: modelId, contextWindow: 128000, maxTokens: 16384 }];
}
existing.providers["9router"] = {
baseUrl: normalizedBaseUrl,
apiKey: apiKey || "sk_9router",
api: "openai-completions",
models: modelList,
};
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
return NextResponse.json({
success: true,
message: "Pi settings applied! Use /model in Pi to select the 9Router model.",
configPath,
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function DELETE() {
try {
const configPath = await resolveModelsJsonPath();
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {
return NextResponse.json({ success: true, message: "No config file to reset" });
}
if (existing.providers && existing.providers["9router"]) {
delete existing.providers["9router"];
if (Object.keys(existing.providers).length === 0) delete existing.providers;
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
}
return NextResponse.json({ success: true, message: "9Router removed from Pi" });
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}

View File

@@ -0,0 +1,142 @@
"use server";
import { NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
const getSmeltConfigPath = () => path.join(os.homedir(), ".smelt", "config.json");
const getSmeltDir = () => path.dirname(getSmeltConfigPath());
const checkSmeltInstalled = async () => {
const isWindows = os.platform() === "win32";
try {
const command = isWindows ? "where smelt" : "which smelt";
await execAsync(command, { windowsHide: true });
return true;
} catch {
try {
await fs.access(getSmeltConfigPath());
return true;
} catch {
return false;
}
}
};
const has9RouterConfig = (settings) => {
if (!settings) return false;
return (
settings._managedBy === "9router" ||
(typeof settings.baseUrl === "string" && settings.baseUrl.length > 0 && settings.baseUrl.includes("20128"))
);
};
const readConfig = async () => {
try {
const content = await fs.readFile(getSmeltConfigPath(), "utf-8");
return JSON.parse(content);
} catch {
return null;
}
};
export async function GET() {
try {
const installed = await checkSmeltInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
config: null,
message: "Smelt CLI is not installed",
});
}
const config = await readConfig();
return NextResponse.json({
installed: true,
config,
has9Router: has9RouterConfig(config),
configPath: getSmeltConfigPath(),
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
}
try {
const { baseUrl, apiKey, model } = rawBody || {};
if (!baseUrl) {
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
}
const configPath = getSmeltConfigPath();
await fs.mkdir(getSmeltDir(), { recursive: true });
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {}
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
const updated = {
...existing,
baseUrl: normalizedBaseUrl,
apiKey: apiKey || "sk_9router",
model: model || existing.model || "provider/model-id",
_managedBy: "9router",
};
await fs.writeFile(configPath, JSON.stringify(updated, null, 2), "utf-8");
return NextResponse.json({
success: true,
message: "Smelt settings applied successfully!",
configPath,
});
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}
export async function DELETE() {
try {
const configPath = getSmeltConfigPath();
let existing = {};
try {
const raw = await fs.readFile(configPath, "utf-8");
existing = JSON.parse(raw);
} catch {
return NextResponse.json({ success: true, message: "No config file to reset" });
}
delete existing.baseUrl;
delete existing.apiKey;
delete existing.model;
delete existing._managedBy;
if (Object.keys(existing).length === 0) {
await fs.rm(configPath, { force: true });
} else {
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
}
return NextResponse.json({ success: true, message: "Smelt 9Router settings removed" });
} catch (err) {
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
}
}

View File

@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { getCombos, createCombo, getProviderConnections } from "@/lib/localDb";
import { buildPresetItems, PRESET_SOURCES } from "@/lib/comboPresets";
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
export const dynamic = "force-dynamic";
/**
* Resolve live Cursor catalog from the first active cursor connection, if any.
* @returns {Promise<Array<{id: string, name?: string}>|null>}
*/
async function fetchCursorLiveModels() {
try {
const connections = await getProviderConnections();
const conn = (connections || []).find(
(c) => c.provider === "cursor" && c.isActive !== false
);
if (!conn) return null;
const result = await resolveCursorModels({
accessToken: conn.accessToken,
providerSpecificData: conn.providerSpecificData || {},
}, { log: console });
return result?.models?.length ? result.models : null;
} catch (error) {
console.log("combo presets: cursor live catalog failed", error?.message || error);
return null;
}
}
/**
* @param {string} source
* @returns {Promise<{ name: string, models: string[], exists: boolean }[]>}
*/
async function resolvePresetItems(source) {
const combos = await getCombos();
const existingNames = (combos || []).map((c) => c.name);
const liveModels = source === "cursor" ? await fetchCursorLiveModels() : null;
return buildPresetItems(source, {
liveModels: liveModels || undefined,
existingNames,
});
}
function parseSource(value) {
if (!value || !PRESET_SOURCES.has(value)) return null;
return value;
}
// GET /api/combos/presets?source=cursor|claude — preview items
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const source = parseSource(searchParams.get("source"));
if (!source) {
return NextResponse.json(
{ error: "source must be 'cursor' or 'claude'" },
{ status: 400 }
);
}
const items = await resolvePresetItems(source);
return NextResponse.json({
source,
items,
toCreate: items.filter((i) => !i.exists).length,
toSkip: items.filter((i) => i.exists).length,
});
} catch (error) {
console.log("Error previewing combo presets:", error);
return NextResponse.json({ error: "Failed to preview combo presets" }, { status: 500 });
}
}
// POST /api/combos/presets — create missing combos for a source
export async function POST(request) {
try {
const body = await request.json().catch(() => ({}));
const source = parseSource(body?.source);
if (!source) {
return NextResponse.json(
{ error: "source must be 'cursor' or 'claude'" },
{ status: 400 }
);
}
const items = await resolvePresetItems(source);
const created = [];
const skipped = [];
for (const item of items) {
if (item.exists) {
skipped.push(item.name);
continue;
}
const combo = await createCombo({ name: item.name, models: item.models });
created.push(combo);
}
return NextResponse.json({
source,
created,
skipped,
createdCount: created.length,
skippedCount: skipped.length,
});
} catch (error) {
console.log("Error creating combo presets:", error);
return NextResponse.json({ error: "Failed to create combo presets" }, { status: 500 });
}
}

View File

@@ -139,6 +139,36 @@ export async function pingModelByKind(
return { ok: true, latencyMs, error: null, status: res.status };
}
if (kind === "systemone") {
const res = await fetch(`${baseUrl}/api/v1/systemone`, {
method: "POST",
headers,
body: JSON.stringify({
model,
state: "Customer: I was charged twice for my order this morning.",
questions: {
probe: { type: "noul", instructions: "Is the customer reporting a billing problem?" },
},
}),
signal: AbortSignal.timeout(15000),
});
const latencyMs = Date.now() - start;
const rawText = await res.text().catch(() => "");
let parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch {}
if (!res.ok) {
const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText;
return { ok: false, latencyMs, error: `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`, status: res.status };
}
const hasAnswers = parsed?.answers && typeof parsed.answers === "object" && Object.keys(parsed.answers).length > 0;
if (!hasAnswers) {
return { ok: false, latencyMs, status: res.status, error: "Provider returned no answers for this model" };
}
return { ok: true, latencyMs, error: null, status: res.status };
}
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
method: "POST",
headers,

View File

@@ -263,6 +263,7 @@ export async function GET(request, { params }) {
"codebuddy-cn",
"codebuddy-intl",
"qoder",
"qoder-cn",
"grok-cli",
];
let deviceData;
@@ -505,7 +506,7 @@ export async function POST(request, { params }) {
} else if (provider === "kiro") {
// Kiro needs extraData (clientId, clientSecret) from device code response
result = await pollForToken(provider, deviceCode, null, extraData);
} else if (provider === "qoder") {
} else if (provider === "qoder" || provider === "qoder-cn") {
// Qoder needs both the PKCE verifier (codeVerifier) and the machineId
// captured at device-code time (extraData._qoderMachineId) so
// mapTokens can persist it for COSY signing.

View File

@@ -10,17 +10,19 @@ import { createProviderConnection } from "@/models";
*/
export async function POST(request) {
try {
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId } = await request.json();
const { apiKey, uid, baseUrl, mimoPassToken, mimoUserId, mimoCUserId, region } = await request.json();
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
const key = typeof apiKey === "string" ? apiKey.trim() : "";
const sessionOnly = !key && !!mimoPassToken;
if (!key && !mimoPassToken) {
return NextResponse.json(
{ error: "API key is required" },
{ status: 400 },
);
}
const key = apiKey.trim();
if (!key.startsWith("sk-")) {
if (key && !key.startsWith("sk-")) {
return NextResponse.json(
{ error: "Invalid key format — expected sk- prefix" },
{ status: 400 },
@@ -29,47 +31,55 @@ export async function POST(request) {
const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, "");
// Validate the key against the models endpoint
// Validate the key against the models endpoint (skipped for session-only)
let validated = false;
let modelCount = 0;
try {
const resp = await fetch(`${effectiveBaseUrl}/models`, {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
"X-Mimo-Source": "mimocode-cli",
},
signal: AbortSignal.timeout(10000),
});
if (resp.ok) {
const data = await resp.json();
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
validated = true;
if (key) {
try {
const resp = await fetch(`${effectiveBaseUrl}/models`, {
method: "GET",
headers: {
Authorization: `Bearer ${key}`,
"X-Mimo-Source": "mimocode-cli",
},
signal: AbortSignal.timeout(10000),
});
if (resp.ok) {
const data = await resp.json();
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
validated = true;
}
} catch {
// Network error — still allow import (key may be valid but network blocked)
}
} catch {
// Network error — still allow import (key may be valid but network blocked)
}
if (!validated) {
if (key && !validated) {
// Soft-fail: store the key but mark as untested
console.log("[xiaomi-mimo] key validation failed, storing as untested");
}
// Dedup: if a connection with the same uid or same key already exists, update it
// Dedup: same uid, same key, or same session identity+region
const { getProviderConnections, updateProviderConnection } = await import("@/models");
const normRegion = (typeof region === "string" && region) || undefined;
const existing = (await getProviderConnections()).find(
(c) => c.provider === "xiaomi-mimo" && (
(uid && c.email === `${uid}@xiaomi`) ||
c.accessToken === key
(key && c.accessToken === key) ||
(sessionOnly && mimoUserId &&
c.providerSpecificData?.mimoUserId === mimoUserId &&
(normRegion ? (c.providerSpecificData?.region || "cn") === normRegion : true))
),
);
if (existing) {
const updated = await updateProviderConnection(existing.id, {
accessToken: key,
accessToken: key || existing.accessToken,
providerSpecificData: {
...existing.providerSpecificData,
uid: uid || existing.providerSpecificData?.uid || null,
baseUrl: effectiveBaseUrl,
baseUrl: key ? effectiveBaseUrl : (existing.providerSpecificData?.baseUrl || effectiveBaseUrl),
region: normRegion || existing.providerSpecificData?.region || "cn",
authMethod: sessionOnly ? "session" : (existing.providerSpecificData?.authMethod || "api_key"),
// Per-account session credential — enables multi-account rotation.
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
@@ -94,25 +104,29 @@ export async function POST(request) {
const connection = await createProviderConnection({
provider: "xiaomi-mimo",
authType: "api_key",
accessToken: key,
// "oauth" is the official authType for imported credential connections
// ([action]/route.js) — the list card and filters key off it; never
// invent new values ("session" hid the row from the provider card).
authType: sessionOnly ? "oauth" : "api_key",
accessToken: key || null,
refreshToken: null,
// API keys don't expire on a fixed schedule; use a long horizon
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
email: uid ? `${uid}@xiaomi` : null,
displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo",
displayName: uid ? `Xiaomi ${uid}${sessionOnly ? " (Session)" : ""}` : "Xiaomi MiMo",
providerSpecificData: {
uid: uid || null,
baseUrl: effectiveBaseUrl,
authMethod: "api_key",
provider: "API Key",
authMethod: sessionOnly ? "session" : "api_key",
provider: sessionOnly ? "Session Login" : "API Key",
region: normRegion || "cn",
modelCount,
// Per-account session credential — enables multi-account rotation.
mimoPassToken: mimoPassToken || null,
mimoUserId: mimoUserId || null,
mimoCUserId: mimoCUserId || null,
},
testStatus: validated ? "active" : "untested",
testStatus: validated ? "active" : (sessionOnly ? "active" : "untested"),
});
return NextResponse.json({

View File

@@ -0,0 +1,140 @@
import { NextResponse } from "next/server";
import { request as httpRequest } from "node:http";
import { beginSession, encodeSessionCookie, rewriteMimoBases, absorbSetCookies as absorbResponseCookies, originOf, loginUpstreamFetch, SESSION_COOKIE } from "@/lib/mimoLoginSession";
/**
* POST /api/oauth/xiaomi-mimo/login/start
* Body: { region: "cn" | "sgp" | "ams" | "ru" | "in" }
*
* Walks the first two hops of the Desktop login surface server-side
* (me -> 302 account/pass/serviceLogin -> 302 /fe/service/login) and hands
* the browser a same-origin pageUrl carrying the 9r_mimo_login session cookie.
* All subsequent account.xiaomi.com traffic flows through src/proxy.js.
*
* Egress resolution: MIMO_LOGIN_PROXY env > (region=sgp: probe common LOCAL
* HTTP proxy ports — v2rayN/clash defaults) > direct. The resolved URL rides
* the session cookie so every hop/XHR uses the same exit.
*/
const API_UA =
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
const SSO_UA = "MiClaw/1.0";
const LOCAL_PROXY_PORTS = [10808, 10809, 7890, 7891, 1080, 1081, 8080, 8888];
/** First local port answering a CONNECT to account.xiaomi.com (or null). */
function probeLocalHttpProxy(timeoutMs = 500) {
const attempts = LOCAL_PROXY_PORTS.map(
(port) =>
new Promise((resolve, reject) => {
let settled = false;
const done = (v) => {
if (settled) return;
settled = true;
// Promise.any picks the first FULFILLED value — failures must reject,
// otherwise an instant ECONNREFUSED from a closed candidate port would
// "win" with null before the real proxy answers.
if (v) resolve(v);
else reject(new Error(`no-proxy-${port}`));
};
try {
const req = httpRequest({
host: "127.0.0.1",
port,
method: "CONNECT",
path: "account.xiaomi.com:443",
timeout: timeoutMs,
});
req.on("connect", (res, socket) => {
socket.destroy();
done(res.statusCode === 200 || res.statusCode === 202 ? `http://127.0.0.1:${port}` : null);
});
req.on("timeout", () => { req.destroy(); done(null); });
req.on("error", () => done(null));
req.on("response", () => done(null));
req.end();
} catch {
done(null);
}
}),
);
return Promise.any(attempts).catch(() => null);
}
async function hop(sess, url, ua) {
return loginUpstreamFetch(url, {
redirect: "manual",
headers: { "User-Agent": ua, Accept: "text/html,application/json,*/*" },
signal: AbortSignal.timeout(15000),
}, sess);
}
export async function POST(request) {
try {
let region = "cn";
try {
const body = await request.json();
const r = String(body?.region || "").toLowerCase();
// Known MiMo Desktop clusters (cn/sgp/ams/ru/in) — default cn.
if (r === "cn" || r === "sgp" || r === "ams" || r === "ru" || r === "in") region = r;
} catch { /* empty body — default cn */ }
const sess = beginSession(region);
// Egress — non-CN clusters may need an overseas exit for the login page's
// geo-decided features (e.g. Google sign-in); CN is always direct.
let egress = null;
let egressSource = "direct";
if (region !== "cn") {
const found = await probeLocalHttpProxy();
if (found) {
egress = found;
egressSource = "local-probe";
}
}
sess.proxyUrl = egress;
// Hop 1: me -> account SSO (callback carries the sts callback for THIS cluster)
const meRes = await hop(sess, `${sess.upstreamBase}/api/user/xiaomi/me`, API_UA);
absorbResponseCookies(sess, meRes, `${sess.upstreamBase}/api/user/xiaomi/me`);
const ssoLoc = meRes.headers.get("location");
if (!ssoLoc || !/account\.xiaomi\.com/.test(ssoLoc)) {
return NextResponse.json(
{ error: `Unexpected me response (${meRes.status}) — no account redirect` },
{ status: 502 },
);
}
// Hop 2: serviceLogin -> /fe/service/login SPA (also seeds deviceId cookies)
const loginRes = await hop(sess, ssoLoc, SSO_UA);
absorbResponseCookies(sess, loginRes, ssoLoc);
const pageLoc = loginRes.headers.get("location");
if (!pageLoc) {
return NextResponse.json(
{ error: `Unexpected serviceLogin response (${loginRes.status})` },
{ status: 502 },
);
}
// Same-origin path for the SPA (middleware proxies native prefixes).
const pageUrl = new URL(pageLoc, "https://account.xiaomi.com");
const origin = originOf(request);
// Session travels ONLY in the httpOnly cookie — never in the URL (history,
// logs, Referer). /login/status re-arms the cookie on every poll, so a
// dropped-cookie browser still recovers on the next poll cycle.
const proxiedPath = rewriteMimoBases(pageUrl.pathname + pageUrl.search, "toProxy", origin);
const egressLog = egress ? egress.replace(/\/\/[^@/]+@/, "//***@") : "";
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start region=${sess.region} origin=${origin} egress=${egressSource}${egressLog ? ` (${egressLog})` : ""} page=${pageUrl.pathname}`);
const res = NextResponse.json({ success: true, state: sess.state, pageUrl: proxiedPath, region });
res.cookies.set(SESSION_COOKIE, encodeSessionCookie(sess), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 15 * 60,
});
return res;
} catch (error) {
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start error:`, error?.message || error);
return NextResponse.json({ error: error?.message || "login start failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { sessionFromRequest, readSessionIdentity, attachSessionCookie } from "@/lib/mimoLoginSession";
/**
* GET /api/oauth/xiaomi-mimo/login/status?state=...
* Polls the server-side login session (state lives in the httpOnly session
* cookie — route handlers and the proxy don't share module memory). When a
* passToken is in the jar, probes /api/user/xiaomi/me once to confirm the
* session works, then returns the identity for the client to persist.
*/
export async function GET(request) {
const url = new URL(request.url);
const state = url.searchParams.get("state") || "";
const sess = sessionFromRequest(request);
if (!sess || (state && sess.state !== state)) {
return NextResponse.json({ status: "expired" }, { status: 404 });
}
if (sess.status !== "done") {
// AUTHORIZATION = passToken in the jar (captured during the proxied login
// XHRs). No serviceToken exchange — weekly-quota API moved; re-wire later.
if (readSessionIdentity(sess)) sess.status = "done";
}
if (sess.status !== "done") {
// Re-arm the session cookie on every poll — the modal may sit on the login
// form much longer than the 15min TTL, and only proxied responses used to
// refresh it (browser silently drops an expired cookie before the POST).
return attachSessionCookie(NextResponse.json({ status: "pending", region: sess.region }), sess);
}
const id = readSessionIdentity(sess);
if (!id) {
return attachSessionCookie(
NextResponse.json({ status: "error", error: "session captured but passToken missing" }),
sess,
);
}
const payload = { status: "done", region: sess.region, ...id };
// One-shot: don't let the identity linger past the client reading it.
const res = NextResponse.json(payload);
res.cookies.set("9r_mimo_login", "", { path: "/", httpOnly: true, maxAge: 0 });
return res;
}

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