diff --git a/.github/issue-assets/combo-defaults/cursor-model-list.png b/.github/issue-assets/combo-defaults/cursor-model-list.png new file mode 100644 index 00000000..93abfbb1 Binary files /dev/null and b/.github/issue-assets/combo-defaults/cursor-model-list.png differ diff --git a/.github/issue-assets/combo-defaults/override-openai-base-url.png b/.github/issue-assets/combo-defaults/override-openai-base-url.png new file mode 100644 index 00000000..e7923899 Binary files /dev/null and b/.github/issue-assets/combo-defaults/override-openai-base-url.png differ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e8ef957e..64be9c1c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -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 || ""}`); + 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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7667b8f9..1d6a7d07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DOCKER.md b/DOCKER.md index 1f280d97..727d7bfb 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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: +``` + +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` diff --git a/Dockerfile b/Dockerfile index afad1fd2..1a426ecd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 2b81116b..1e4d3386 100644 --- a/README.md +++ b/README.md @@ -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: 🇻🇳 Tiếng Việt
Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z
by tuanminhhole
- + + + Bye Limit! Cara Bikin Sistem 'AI Unlimited' 100% Gratis Dengan 9Router!
+ +
+ 🇮🇩 Indonesia
+ Bye Limit! Cara Bikin Sistem "AI Unlimited" 100% Gratis Dengan 9Router!
by neptiver
+ diff --git a/cli/README.md b/cli/README.md index 050d996a..19ad230f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 --- diff --git a/cli/package.json b/cli/package.json index c75e6dc9..f7a0c677 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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" diff --git a/gitbook/content/en/features/combos.md b/gitbook/content/en/features/combos.md index 2b94eff5..f91c1f00 100644 --- a/gitbook/content/en/features/combos.md +++ b/gitbook/content/en/features/combos.md @@ -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) diff --git a/next.config.mjs b/next.config.mjs index ecd385ca..517986c9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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*" diff --git a/open-sse/AGENTS.md b/open-sse/AGENTS.md index 068855e5..57aef4d1 100644 --- a/open-sse/AGENTS.md +++ b/open-sse/AGENTS.md @@ -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 diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index afa98fa7..f4747cd4 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -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]; diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index da4a7981..b8a25eed 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -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 }; diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 0aefc623..8f20040d 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -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 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({ diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index f48a8ecb..e8b06ea6 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -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"; diff --git a/open-sse/executors/opencode-zen.js b/open-sse/executors/opencode-zen.js new file mode 100644 index 00000000..8a43ff85 --- /dev/null +++ b/open-sse/executors/opencode-zen.js @@ -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; + } +} diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 507c3718..4623dcf6 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -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 }); } diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 10dc42bf..b913055c 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -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: \n\n`. Errors become a synthetic OpenAI error - * chunk + [DONE]. + * and re-emit as `data: \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 }; } diff --git a/open-sse/executors/xiaomi-mimo.js b/open-sse/executors/xiaomi-mimo.js index 8b69412a..79f1bf30 100644 --- a/open-sse/executors/xiaomi-mimo.js +++ b/open-sse/executors/xiaomi-mimo.js @@ -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/` 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; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 76d599da..3005fde3 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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; } } diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 2174f959..ac7d633c 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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": "*" } }) }; diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index ccb782fa..e45c2efd 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -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": "*", diff --git a/open-sse/handlers/imageProviders/huggingface.js b/open-sse/handlers/imageProviders/huggingface.js index 2093d9c3..371b18f8 100644 --- a/open-sse/handlers/imageProviders/huggingface.js +++ b/open-sse/handlers/imageProviders/huggingface.js @@ -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 `//`. `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(); diff --git a/open-sse/handlers/systemoneCore.js b/open-sse/handlers/systemoneCore.js new file mode 100644 index 00000000..82da570d --- /dev/null +++ b/open-sse/handlers/systemoneCore.js @@ -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": "*", + }, + }), + }; +} diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index fea77729..ef331149 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -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 + -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. diff --git a/open-sse/providers/index.js b/open-sse/providers/index.js index ab5123b1..641d2a20 100644 --- a/open-sse/providers/index.js +++ b/open-sse/providers/index.js @@ -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", ]); diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index 428e66ad..d0bc9302 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -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" }, diff --git a/open-sse/providers/registry/commandcode.js b/open-sse/providers/registry/commandcode.js index 059af4a0..75631b8c 100644 --- a/open-sse/providers/registry/commandcode.js +++ b/open-sse/providers/registry/commandcode.js @@ -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" }, diff --git a/open-sse/providers/registry/huggingface.js b/open-sse/providers/registry/huggingface.js index 768b0ded..7eafea1c 100644 --- a/open-sse/providers/registry/huggingface.js +++ b/open-sse/providers/registry/huggingface.js @@ -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 + // `//` — 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 ". + 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/`). + // 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", + }, }; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index fb355d6d..0e8dbe2e 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -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, diff --git a/open-sse/providers/registry/openai.js b/open-sse/providers/registry/openai.js index 4a5f0eb1..ba87329c 100644 --- a/open-sse/providers/registry/openai.js +++ b/open-sse/providers/registry/openai.js @@ -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" }, diff --git a/open-sse/providers/registry/opencode-zen.js b/open-sse/providers/registry/opencode-zen.js new file mode 100644 index 00000000..7252d7a4 --- /dev/null +++ b/open-sse/providers/registry/opencode-zen.js @@ -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, + }, +}; diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index b0705066..5a7926ef 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -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, }; diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 68a92857..83d0f54f 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -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", diff --git a/open-sse/providers/registry/qoder-cn.js b/open-sse/providers/registry/qoder-cn.js new file mode 100644 index 00000000..a8809cfa --- /dev/null +++ b/open-sse/providers/registry/qoder-cn.js @@ -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, + }, +}; diff --git a/open-sse/providers/registry/xiaomi-mimo.js b/open-sse/providers/registry/xiaomi-mimo.js index cb0139b3..a92d48c5 100644 --- a/open-sse/providers/registry/xiaomi-mimo.js +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -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-.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- / mimo. + 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" }, diff --git a/open-sse/providers/schema.js b/open-sse/providers/schema.js index e8b5ddd1..eddf201b 100644 --- a/open-sse/providers/schema.js +++ b/open-sse/providers/schema.js @@ -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. diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index c0699c6f..176c82e9 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -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 = { diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index a5c6ce99..7589b9e6 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -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. diff --git a/open-sse/rtk/index.js b/open-sse/rtk/index.js index dd1e5018..b8488baa 100644 --- a/open-sse/rtk/index.js +++ b/open-sse/rtk/index.js @@ -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"; diff --git a/open-sse/services/capacityAdapter.js b/open-sse/services/capacityAdapter.js index 7b096f97..b18626aa 100644 --- a/open-sse/services/capacityAdapter.js +++ b/open-sse/services/capacityAdapter.js @@ -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: [] }; diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index e9a7879b..b5bd70fb 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -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 } * 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, diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 63256dd3..d02082ff 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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 = { diff --git a/open-sse/services/usage/antigravity-weekly.js b/open-sse/services/usage/antigravity-weekly.js index db92a224..c0b78884 100644 --- a/open-sse/services/usage/antigravity-weekly.js +++ b/open-sse/services/usage/antigravity-weekly.js @@ -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; } } } diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js index 736722a7..197f4364 100644 --- a/open-sse/services/usage/google.js +++ b/open-sse/services/usage/google.js @@ -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; } } } diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js index e4b04589..567739e8 100644 --- a/open-sse/services/usage/misc.js +++ b/open-sse/services/usage/misc.js @@ -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..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 */ 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: { diff --git a/open-sse/services/usage/opencode-zen.js b/open-sse/services/usage/opencode-zen.js new file mode 100644 index 00000000..54dacec8 --- /dev/null +++ b/open-sse/services/usage/opencode-zen.js @@ -0,0 +1,107 @@ +/** + * OpenCode Zen usage — GET https://opencode.ai/zen/v1/usage + * Auth: Bearer + */ + +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}` }; + } +} diff --git a/open-sse/shared/mimoAccount.js b/open-sse/shared/mimoAccount.js index 996f38df..9da593a8 100644 --- a/open-sse/shared/mimoAccount.js +++ b/open-sse/shared/mimoAccount.js @@ -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=&_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=&_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- / sid = mimo +// (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. +// 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=&_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} 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, ); diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 849f67ed..04244b44 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -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" && diff --git a/open-sse/translator/concerns/finishReason.js b/open-sse/translator/concerns/finishReason.js index 684a7001..6bfd021b 100644 --- a/open-sse/translator/concerns/finishReason.js +++ b/open-sse/translator/concerns/finishReason.js @@ -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: diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index 863b627e..b5c8a926 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -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) { diff --git a/open-sse/translator/concerns/thinking.js b/open-sse/translator/concerns/thinking.js index 3db892c4..4bcf559c 100644 --- a/open-sse/translator/concerns/thinking.js +++ b/open-sse/translator/concerns/thinking.js @@ -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). diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 2fcbcbf8..8a1add5f 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -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; diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 305eaca0..918ba9c4 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -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: [], diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 4651d3cf..80de2f18 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -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) { diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index bd435f9c..c3d16448 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -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 } : {}) } }); } diff --git a/open-sse/translator/schema/finishReasons.js b/open-sse/translator/schema/finishReasons.js index 73535dae..88c320f1 100644 --- a/open-sse/translator/schema/finishReasons.js +++ b/open-sse/translator/schema/finishReasons.js @@ -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. diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js index c870921b..47b3e0ba 100644 --- a/open-sse/utils/cursorProtobuf.js +++ b/open-sse/utils/cursorProtobuf.js @@ -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, }; diff --git a/open-sse/utils/opencodeFingerprint.js b/open-sse/utils/opencodeFingerprint.js new file mode 100644 index 00000000..db7ba03a --- /dev/null +++ b/open-sse/utils/opencodeFingerprint.js @@ -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 }} 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} 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; +} diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index ad518fe8..b8b4a071 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -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}`, }; diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 2115fef3..fd7fc44c 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -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; diff --git a/package.json b/package.json index a87b8812..d7d4013b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.81", + "version": "0.5.86", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index 5de38ff0..178da2ae 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -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": "印度" } diff --git a/public/providers/codewhale.svg b/public/providers/codewhale.svg new file mode 100644 index 00000000..412e4c14 --- /dev/null +++ b/public/providers/codewhale.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/providers/crush.png b/public/providers/crush.png new file mode 100644 index 00000000..c893222e Binary files /dev/null and b/public/providers/crush.png differ diff --git a/public/providers/forge.png b/public/providers/forge.png new file mode 100644 index 00000000..056562ac Binary files /dev/null and b/public/providers/forge.png differ diff --git a/public/providers/omp.png b/public/providers/omp.png new file mode 100644 index 00000000..9da36e29 Binary files /dev/null and b/public/providers/omp.png differ diff --git a/public/providers/pi.svg b/public/providers/pi.svg new file mode 100644 index 00000000..e2851685 --- /dev/null +++ b/public/providers/pi.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/providers/qoder-cn.png b/public/providers/qoder-cn.png new file mode 100644 index 00000000..59da3da5 Binary files /dev/null and b/public/providers/qoder-cn.png differ diff --git a/public/providers/smelt.svg b/public/providers/smelt.svg new file mode 100644 index 00000000..917aea73 --- /dev/null +++ b/public/providers/smelt.svg @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index 82211e48..f5df083a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -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 ; case "grok-build": return ; + case "pi": + case "omp": + case "crush": + case "forge": + case "smelt": + case "codewhale": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GenericCliToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GenericCliToolCard.js new file mode 100644 index 00000000..76b95eec --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/GenericCliToolCard.js @@ -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 ( + + {/* Header clickable */} +
+
+
+ {tool.image ? ( + {tool.name} { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" + /> + ) : tool.icon ? ( + + {tool.icon} + + ) : ( + terminal + )} +
+
+
+

{tool.name}

+ {configStatus === "configured" && ( + + Connected + + )} + {configStatus === "not_configured" && ( + + Not configured + + )} + {configStatus === "other" && ( + + Other + + )} +
+

{tool.description}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking {tool.name}... +
+ )} + + {!checking && status && !status.installed && ( +
+
+
+ warning +
+

{tool.name} not detected locally

+

Manual configuration is still available if 9router is deployed on a remote server.

+
+
+
+ + +
+
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

Install command:

+ {getInstallCommand()} +
+ {tool.docsUrl && ( +

+ Docs: {tool.docsUrl} +

+ )} +
+
+ )} +
+ )} + + {!checking && status?.installed && ( + <> +
+ {/* Endpoint (selector) */} +
+ Select Endpoint + arrow_forward + +
+ + {/* Current configured */} + {currentBaseUrl ? ( +
+ Current + arrow_forward + + {currentBaseUrl} + +
+ ) : null} + + {/* API Key */} +
+ API Key + arrow_forward + +
+ + {/* Models selector cho Pi (multi-models) */} + {tool.id === "pi" && ( +
+ Models + arrow_forward +
+
+ {selectedModels.length === 0 ? ( + No models selected. Add models to use in Pi. + ) : ( + selectedModels.map((modelId) => ( + + {modelId} + + + )) + )} +
+
+ + {activeProviders?.length > 0 && ( + + )} + {selectedModels.length > 0 && ( + + )} +
+
+
+ )} + + {/* Model (1 model cho các tool khác, trừ omp) */} + {tool.id !== "omp" && tool.id !== "pi" && ( +
+ Model + arrow_forward +
+ 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 && ( + + )} +
+ +
+ )} +
+ + {/* Messages */} + {message && ( +
+ {message.text} +
+ )} + + {/* Action Buttons */} +
+
+ + {status?.has9Router && ( + + )} +
+ +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleSelectModel} + activeProviders={activeProviders} + /> + + setShowManualConfigModal(false)} + title={`${tool.name} Configuration`} + configs={getManualConfigContent()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index e1399677..8a1465ba 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -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"; diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index edfe2431..498a772d 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -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 ( +
+ + +
+ ); + } return (
{/* Header */} -
+

Group models under one name, then pick a strategy per combo: @@ -281,10 +442,40 @@ export default function CombosPage() {

  • Round Robin — rotates models across requests to spread load
  • Fusion — 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)
  • +

    + Cursor / Claude Default create combos named exactly like those clients' model IDs (e.g. composer-2.5, opus), seeded with the matching cu/… or cc/… route so traffic can hit 9router without the prefix. + {" "}Note: Cursor IDE itself often blocks built-in Composer / Grok from Override OpenAI Base URL ("model does not support custom API"); add them via Cursor's Add Custom Model using the combo name, or pick a model Cursor allows through the custom endpoint. +

    +
    +
    + +
    + + +
    -
    {/* Tag filter bar — chips toggle inclusion. OR semantics. */} @@ -333,75 +524,108 @@ export default function CombosPage() {
    - ) : activeTagFilters.size > 0 ? ( -
    - {[...groupedCombos.entries()].map(([tag, list]) => ( -
    -
    - sell -

    - {tag === "__untagged__" ? "Untagged" : tag} -

    - ({list.length}) -
    + ) : ( +
    + {/* Selection toolbar */} +
    + + +
    + {someSelected && ( + <> +
    + e.stopPropagation()} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" + aria-label={`Select ${combo.name}`} + /> +
    layers
    @@ -476,7 +716,11 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi combo.models.slice(0, 3).map((model, index) => ( {model} - + )) )} @@ -495,6 +739,13 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi ))}
    )} + {comboCaps && ( +
    + ctx {fmtK(comboCaps.contextWindow)} + · + max {fmtK(comboCaps.maxOutput)} +
    + )} {/* Fusion: judge picker (Auto = first model) */} {isFusion && (
    @@ -618,10 +869,6 @@ function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, ge

    Your model can't read image/audio? Auto-switches to a model in the pool below.

    -
      -
    • Vision — images (png, jpg, webp, …)
    • -
    • Audio — audio input
    • -
    diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js index ff30dcb2..a1e456f0 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -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 }) {
    + {/* Question for System One */} + {kind === "systemone" && ( + +
    + 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 && ( + + )} +
    +
    + )} + {/* Reference image (only for edit-capable image models) */} {supportsEdit && ( diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js index e7b2a304..11e0a7d7 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js @@ -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}`, + }, }; diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index c331267b..4cbed901 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -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) && ( {isCloudflareAi ? <>One key per line. Format: name|apiKey|accountId or just apiKey (auto-named by index). - : provider === "qoder" + : provider === "qoder" || provider === "qoder-cn" ? <>One PAT per line. Format: name|pt-... or just pt-... (auto-named by index). : <>One key per line. Format: name|apiKey or just apiKey (auto-named by index). } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 62e9c849..ea7357e9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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 - {/* 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) && ( + +
    +
    + + {!chartData.length ? ( +
    No provider usage yet
    + ) : ( + + + + v.length > 10 ? v.slice(0, 10) + "…" : v} + /> + + [fmt(value), label]} + /> + + {chartData.map((_, i) => ( + + ))} + + + + )} + + ); +} + +ProviderBarChart.propTypes = { + byProvider: PropTypes.object, +}; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js index 541cc9f0..acb52853 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js @@ -45,6 +45,7 @@ export default function ProviderLimitCard({ codex: "#10A37F", kiro: "#FF9900", qoder: "#EC4899", + "qoder-cn": "#EC4899", claude: "#D97757", }; return colors[provider?.toLowerCase()] || "#6B7280"; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 4112cea7..1106d2c5 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -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; diff --git a/src/app/(dashboard)/dashboard/usage/components/TopModelsChart.js b/src/app/(dashboard)/dashboard/usage/components/TopModelsChart.js new file mode 100644 index 00000000..abf87cd7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/TopModelsChart.js @@ -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 ( + +
    + Top Models +
    + + +
    +
    + + {!chartData.length ? ( +
    No model usage yet
    + ) : ( + + + + + + [fmt(value), label]} + /> + + {chartData.map((_, i) => ( + + ))} + + + + )} +
    + ); +} + +TopModelsChart.propTypes = { + byModel: PropTypes.object, +}; diff --git a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js index 14a19c42..8300e97d 100644 --- a/src/app/(dashboard)/dashboard/usage/components/UsageChart.js +++ b/src/app/(dashboard)/dashboard/usage/components/UsageChart.js @@ -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 ( -
    - - +
    + {VIEW_MODES.map((m) => ( + + ))}
    {loading ? ( @@ -77,6 +90,10 @@ export default function UsageChart({ period = "7d" }) { + + + + @@ -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} /> - name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"] - } + formatter={(value) => [cfg.formatter(value), cfg.label]} + /> + - {viewMode === "tokens" ? ( - - ) : ( - - )} )} diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index 075b211a..2b43198b 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -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() { diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index b4270163..0f4ca8d5 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -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 diff --git a/src/app/api/cli-tools/codewhale-settings/route.js b/src/app/api/cli-tools/codewhale-settings/route.js new file mode 100644 index 00000000..f561dc06 --- /dev/null +++ b/src/app/api/cli-tools/codewhale-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/cli-tools/crush-settings/route.js b/src/app/api/cli-tools/crush-settings/route.js new file mode 100644 index 00000000..dd96d948 --- /dev/null +++ b/src/app/api/cli-tools/crush-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/cli-tools/forge-settings/route.js b/src/app/api/cli-tools/forge-settings/route.js new file mode 100644 index 00000000..2f7c45dc --- /dev/null +++ b/src/app/api/cli-tools/forge-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/cli-tools/omp-settings/route.js b/src/app/api/cli-tools/omp-settings/route.js new file mode 100644 index 00000000..52df53e1 --- /dev/null +++ b/src/app/api/cli-tools/omp-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/cli-tools/pi-settings/route.js b/src/app/api/cli-tools/pi-settings/route.js new file mode 100644 index 00000000..ee5c507b --- /dev/null +++ b/src/app/api/cli-tools/pi-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/cli-tools/smelt-settings/route.js b/src/app/api/cli-tools/smelt-settings/route.js new file mode 100644 index 00000000..6592d0d9 --- /dev/null +++ b/src/app/api/cli-tools/smelt-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/combos/presets/route.js b/src/app/api/combos/presets/route.js new file mode 100644 index 00000000..58d0541b --- /dev/null +++ b/src/app/api/combos/presets/route.js @@ -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|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 }); + } +} diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 9dbbfcaa..ec3654bd 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -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, diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 520be970..a40dd49f 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -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. diff --git a/src/app/api/oauth/xiaomi-mimo/api-key/route.js b/src/app/api/oauth/xiaomi-mimo/api-key/route.js index d8ceee97..02f4fe04 100644 --- a/src/app/api/oauth/xiaomi-mimo/api-key/route.js +++ b/src/app/api/oauth/xiaomi-mimo/api-key/route.js @@ -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({ diff --git a/src/app/api/oauth/xiaomi-mimo/login/start/route.js b/src/app/api/oauth/xiaomi-mimo/login/start/route.js new file mode 100644 index 00000000..712f3bca --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/login/start/route.js @@ -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 }); + } +} diff --git a/src/app/api/oauth/xiaomi-mimo/login/status/route.js b/src/app/api/oauth/xiaomi-mimo/login/status/route.js new file mode 100644 index 00000000..840788ef --- /dev/null +++ b/src/app/api/oauth/xiaomi-mimo/login/status/route.js @@ -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; +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 23f97380..d209a30f 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -127,6 +127,49 @@ const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => asyn return { models: [], warning }; }; +// Qoder shares one resolver across intl (qoder) and CN (qoder-cn); the +// credentials carry the connection's provider so qoderModels picks the right +// region's catalog endpoint, and the ids keep the provider prefix. +function buildQoderModelsResolver(providerId) { + return { + customResolver: async (connection) => { + const credentials = { + provider: providerId, + accessToken: connection.accessToken, + apiKey: connection.apiKey, + refreshToken: connection.refreshToken, + email: connection.email, + displayName: connection.displayName, + providerSpecificData: connection.providerSpecificData || {}, + }; + let warning; + try { + const result = await resolveQoderModels(credentials, { forceRefresh: true }); + if (result?.models?.length) { + return { + models: result.models.map((m) => ({ + // Use the canonical "/" id so the dashboard + // surfaces the same identifier the chat router expects. + id: `${providerId}/${m.id}`, + name: m.name, + contextLength: m.contextLength, + isVL: m.isVL, + isReasoning: m.isReasoning, + maxOutputTokens: m.maxOutputTokens, + description: m.description, + })), + }; + } + warning = "Qoder returned no models; falling back to static catalog."; + } catch (error) { + warning = `Failed to fetch Qoder models: ${error.message}`; + console.log("Failed to fetch Qoder models dynamically, falling back to static:", error.message); + } + return { models: [], warning }; + }, + }; +} + // Provider models endpoints configuration const PROVIDER_MODELS_CONFIG = { claude: { @@ -403,42 +446,8 @@ const PROVIDER_MODELS_CONFIG = { return { models: [], warning }; } }, - qoder: { - customResolver: async (connection) => { - const credentials = { - accessToken: connection.accessToken, - apiKey: connection.apiKey, - refreshToken: connection.refreshToken, - email: connection.email, - displayName: connection.displayName, - providerSpecificData: connection.providerSpecificData || {}, - }; - let warning; - try { - const result = await resolveQoderModels(credentials, { forceRefresh: true }); - if (result?.models?.length) { - return { - models: result.models.map((m) => ({ - // Use the canonical "qoder/" id so the dashboard - // surfaces the same identifier the chat router expects. - id: `qoder/${m.id}`, - name: m.name, - contextLength: m.contextLength, - isVL: m.isVL, - isReasoning: m.isReasoning, - maxOutputTokens: m.maxOutputTokens, - description: m.description, - })), - }; - } - warning = "Qoder returned no models; falling back to static catalog."; - } catch (error) { - warning = `Failed to fetch Qoder models: ${error.message}`; - console.log("Failed to fetch Qoder models dynamically, falling back to static:", error.message); - } - return { models: [], warning }; - }, - }, + qoder: buildQoderModelsResolver("qoder"), + "qoder-cn": buildQoderModelsResolver("qoder-cn"), "gemini-cli": { customResolver: buildOAuthResolver({ refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret), diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 03500da2..b81294d4 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -74,6 +74,14 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", refreshable: false, }, + "qoder-cn": { + // Same shape as intl qoder, CN host. + url: "https://openapi.qoder.com.cn/api/v1/userinfo", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: false, + }, kimi: { checkExpiry: true, refreshable: true }, "kimi-coding": { checkExpiry: true, refreshable: true }, cursor: { tokenExists: true }, @@ -781,12 +789,16 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } - case "qoder": { + case "qoder": + case "qoder-cn": { // PAT (pt-...) exchange → job token. A successful exchange proves the PAT. + const exchangeUrl = provider === "qoder-cn" + ? "https://openapi.qoder.com.cn/api/v1/jobToken/exchange" + : "https://openapi.qoder.sh/api/v1/jobToken/exchange"; const raw = connection.apiKey || ""; const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`; const exRes = await fetchWithConnectionProxy( - "https://openapi.qoder.sh/api/v1/jobToken/exchange", + exchangeUrl, { method: "POST", headers: { diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 7cdabc5e..43d40bc9 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -582,11 +582,12 @@ export async function POST(request) { break; } - case "qoder": { + case "qoder": + case "qoder-cn": { // PAT (pt-...) needs the job-token exchange before it can sign // anything — the generic OpenAI-compat probe below can't validate it. try { - const resolved = await resolveQoderCredentials({ apiKey, providerSpecificData }, null, AbortSignal.timeout(8000)); + const resolved = await resolveQoderCredentials({ provider, apiKey, providerSpecificData }, null, AbortSignal.timeout(8000)); const result = await resolveQoderModels(resolved, { forceRefresh: true }); isValid = !!result?.models?.length; } catch (err) { diff --git a/src/app/api/proxy-pools/vercel-deploy/route.js b/src/app/api/proxy-pools/vercel-deploy/route.js index e87390f5..f6501331 100644 --- a/src/app/api/proxy-pools/vercel-deploy/route.js +++ b/src/app/api/proxy-pools/vercel-deploy/route.js @@ -20,14 +20,15 @@ export default async function handler(req) { const targetUrl = target.replace(/\\/$/, "") + relayPath; - const headers = new Headers(req.headers); - headers.delete("x-relay-target"); - headers.delete("x-relay-path"); - headers.delete("host"); + const rawHeaders = {}; + for (const [k, v] of req.headers.entries()) rawHeaders[k] = v; + delete rawHeaders["x-relay-target"]; + delete rawHeaders["x-relay-path"]; + delete rawHeaders["host"]; const response = await fetch(targetUrl, { method: req.method, - headers, + headers: rawHeaders, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half", }); diff --git a/src/app/api/usage/chart/route.js b/src/app/api/usage/chart/route.js index 063cedd6..0321c3a4 100644 --- a/src/app/api/usage/chart/route.js +++ b/src/app/api/usage/chart/route.js @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { getChartData } from "@/lib/usageDb"; -const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d"]); +const VALID_PERIODS = new Set(["today", "24h", "7d", "30d", "60d", "all"]); export async function GET(request) { try { diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 753ca602..48b623dd 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -18,7 +18,28 @@ import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; -import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; +import { capabilitiesFromServiceKind, getCapabilitiesForModel, aggregateComboCapabilities } from "open-sse/providers/capabilities.js"; + +// Qoder shares one live resolver across intl (qoder) and CN (qoder-cn); the +// credentials carry the provider id so qoderModels picks the right region's +// catalog endpoint. +async function resolveQoderLiveModels(conn, provider) { + const result = await resolveQoderModels({ + provider, + accessToken: conn.accessToken, + // PAT (pt-...) connections keep the token in apiKey; without it the live + // catalog silently fails and /v1/models falls back to the static list. + apiKey: conn.apiKey, + refreshToken: conn.refreshToken, + email: conn.email, + displayName: conn.displayName, + providerSpecificData: conn.providerSpecificData || {} + }); + // Visible + hidden (enable:false) catalog keys — chat routes all of them. + const models = routableQoderModels(result); + if (!models.length) return null; + return { models: models.map((m) => ({ id: m.id, name: m.name })) }; +} // Per-provider live model resolvers. Each receives a connection record and // returns { models: [{ id, name? }, ...] } | null on failure. @@ -32,22 +53,8 @@ const LIVE_MODEL_RESOLVERS = { }, { log: console }); return result?.models?.length ? { models: result.models } : null; }, - qoder: async (conn) => { - const result = await resolveQoderModels({ - accessToken: conn.accessToken, - // PAT (pt-...) connections keep the token in apiKey; without it the live - // catalog silently fails and /v1/models falls back to the static list. - apiKey: conn.apiKey, - refreshToken: conn.refreshToken, - email: conn.email, - displayName: conn.displayName, - providerSpecificData: conn.providerSpecificData || {} - }); - // Visible + hidden (enable:false) catalog keys — chat routes all of them. - const models = routableQoderModels(result); - if (!models.length) return null; - return { models: models.map((m) => ({ id: m.id, name: m.name })) }; - }, + qoder: async (conn) => resolveQoderLiveModels(conn, "qoder"), + "qoder-cn": async (conn) => resolveQoderLiveModels(conn, "qoder-cn"), kimchi: async (conn) => { const result = await resolveKimchiModels({ accessToken: conn.accessToken, @@ -305,6 +312,9 @@ export async function buildModelsList(kindFilter, options = {}) { const models = []; + // Lookup map so aggregateComboCapabilities can recursively resolve nested combos + const comboByName = Object.fromEntries(combos.map((c) => [c.name, c.models])); + // Combos first (filtered by kind). Web combos expose `kind` so AI knows search vs fetch. for (const combo of combos) { if (!comboMatchesKinds(combo, kindFilter)) continue; @@ -315,6 +325,9 @@ export async function buildModelsList(kindFilter, options = {}) { }; if (combo.kind === "webSearch" || combo.kind === "webFetch") { entry.kind = combo.kind; + } else { + const comboCaps = aggregateComboCapabilities(combo.models, comboByName); + if (comboCaps) entry.capabilities = comboCaps; } models.push(entry); } @@ -334,6 +347,7 @@ export async function buildModelsList(kindFilter, options = {}) { id: `${alias}/${model.id}`, object: "model", owned_by: alias, + capabilities: getCapabilitiesForModel(alias, model.id), }); } } diff --git a/src/app/api/v1/systemone/route.js b/src/app/api/v1/systemone/route.js new file mode 100644 index 00000000..0766cf49 --- /dev/null +++ b/src/app/api/v1/systemone/route.js @@ -0,0 +1,21 @@ +import { handleSystemone } from "@/sse/handlers/systemone.js"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * POST /v1/systemone - System One (Jev) decision endpoint + */ +export async function POST(request) { + return await handleSystemone(request); +} diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 3d4b2e32..fab0b291 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -191,6 +191,9 @@ function isPublicApi(pathname) { return PUBLIC_API_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); } +// Shared with src/proxy.js — the mimo login branch must respect dashboard auth. +export { isAuthenticated }; + export const __test__ = { isLocalRequest, isPublicLlmApi, diff --git a/src/i18n/runtime.js b/src/i18n/runtime.js index fef93328..7391a7c9 100644 --- a/src/i18n/runtime.js +++ b/src/i18n/runtime.js @@ -56,12 +56,13 @@ export function onLocaleChange(callback) { // Process text node function processTextNode(node) { - if (!node.nodeValue || !node.nodeValue.trim()) return; - + const current = node.nodeValue; + if (!current || !current.trim()) return; + // Skip if parent is script, style, code, or structural elements const parent = node.parentElement; if (!parent) return; - + // Skip if parent or any ancestor has data-i18n-skip attribute let element = parent; while (element) { @@ -70,27 +71,33 @@ function processTextNode(node) { } element = element.parentElement; } - + const tagName = parent.tagName?.toLowerCase(); - + // Skip elements that don't allow text nodes const skipTags = [ "script", "style", "code", "pre", "colgroup", "table", "thead", "tbody", "tfoot", "tr", "select", "datalist", "optgroup" ]; - + if (skipTags.includes(tagName)) return; - - // Store original text if not already stored - if (!node._originalText) { - node._originalText = node.nodeValue; + + // React reuses text nodes and rewrites their value on re-render (a + // characterData mutation, no childList event). When the current value is + // neither our last translation nor the recorded original, it is fresh + // source text — re-capture it as the new original before translating. + const isOurTranslation = node._translated != null && current === node._translated; + const isSameAsOriginal = current === node._originalText; + if (!isOurTranslation && !isSameAsOriginal) { + node._originalText = current; } - - // Use original text for translation - const original = node._originalText; - const translated = translate(original); - + if (node._originalText == null) node._originalText = current; + + // Translate from the recorded original so locale switches stay idempotent + const translated = translate(node._originalText); + node._translated = translated; + // Only update if different to avoid unnecessary DOM mutations if (translated !== node.nodeValue) { node.nodeValue = translated; @@ -130,9 +137,16 @@ export async function initRuntimeI18n() { // Process existing DOM processElement(document.body); - // Watch for new nodes + // Watch for new nodes AND in-place text rewrites. React reuses text nodes on + // re-render (only nodeValue changes → a characterData mutation with no + // childList event), so observing childList alone leaves later-updated labels + // untranslated. const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { + if (mutation.type === "characterData") { + processTextNode(mutation.target); + return; + } mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { processElement(node); @@ -146,6 +160,7 @@ export async function initRuntimeI18n() { observer.observe(document.body, { childList: true, subtree: true, + characterData: true, }); } diff --git a/src/lib/comboPresets.js b/src/lib/comboPresets.js new file mode 100644 index 00000000..f45ae058 --- /dev/null +++ b/src/lib/comboPresets.js @@ -0,0 +1,121 @@ +/** + * Build Cursor / Claude default combo presets. + * Combo names match client-native model IDs (no provider prefix); + * each is seeded with the matching prefixed 9router model so routing works. + */ + +import { getProviderModels } from "open-sse/config/providerModels.js"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; + +export const VALID_COMBO_NAME_REGEX = /^[a-zA-Z0-9_.\-]+$/; +export const PRESET_SOURCES = new Set(["cursor", "claude"]); + +const CURSOR_ALIAS = "cu"; +const CLAUDE_ALIAS = "cc"; + +/** Extra Claude Code aliases not listed in defaultModels. */ +const CLAUDE_EXTRA_ALIAS_TARGETS = { + default: "cc/claude-sonnet-5", + opusplan: "cc/claude-opus-5", +}; + +/** + * @param {string} name + * @returns {boolean} + */ +export function isValidComboPresetName(name) { + return typeof name === "string" && name.length > 0 && VALID_COMBO_NAME_REGEX.test(name); +} + +/** + * @param {string} name + * @param {string[]} models + * @param {Set} [seen] + * @returns {{ name: string, models: string[] }|null} + */ +function pushItem(name, models, seen) { + if (!isValidComboPresetName(name)) return null; + if (seen?.has(name)) return null; + if (!Array.isArray(models) || models.length === 0) return null; + seen?.add(name); + return { name, models }; +} + +/** + * @param {Array<{id?: string}|string>} modelList + * @param {string} providerAlias + * @param {Set} seen + * @returns {{ name: string, models: string[] }[]} + */ +function itemsFromProviderModels(modelList, providerAlias, seen) { + const out = []; + for (const entry of modelList || []) { + const id = typeof entry === "string" ? entry : entry?.id; + if (!id) continue; + const item = pushItem(id, [`${providerAlias}/${id}`], seen); + if (item) out.push(item); + } + return out; +} + +/** + * Build Cursor default presets. + * Prefer live catalog when provided; otherwise static cu registry. + * @param {{ liveModels?: Array<{id: string}|string> }} [opts] + * @returns {{ name: string, models: string[] }[]} + */ +export function buildCursorPresetItems(opts = {}) { + const seen = new Set(); + const live = Array.isArray(opts.liveModels) ? opts.liveModels : null; + if (live?.length) { + return itemsFromProviderModels(live, CURSOR_ALIAS, seen); + } + return itemsFromProviderModels(getProviderModels(CURSOR_ALIAS), CURSOR_ALIAS, seen); +} + +/** + * Build Claude default presets from cc registry + Claude Code aliases. + * @returns {{ name: string, models: string[] }[]} + */ +export function buildClaudePresetItems() { + const seen = new Set(); + const out = itemsFromProviderModels(getProviderModels(CLAUDE_ALIAS), CLAUDE_ALIAS, seen); + + const claudeTool = CLI_TOOLS.claude || {}; + for (const entry of claudeTool.defaultModels || []) { + const name = entry.alias || entry.id; + const target = entry.defaultValue; + if (!name || !target) continue; + const item = pushItem(name, [target], seen); + if (item) out.push(item); + } + + for (const alias of claudeTool.modelAliases || []) { + if (seen.has(alias)) continue; + const target = CLAUDE_EXTRA_ALIAS_TARGETS[alias]; + if (!target) continue; + const item = pushItem(alias, [target], seen); + if (item) out.push(item); + } + + return out; +} + +/** + * @param {"cursor"|"claude"} source + * @param {{ liveModels?: Array<{id: string}|string>, existingNames?: Iterable }} [opts] + * @returns {{ name: string, models: string[], exists: boolean }[]} + */ +export function buildPresetItems(source, opts = {}) { + if (!PRESET_SOURCES.has(source)) return []; + + const items = source === "cursor" + ? buildCursorPresetItems({ liveModels: opts.liveModels }) + : buildClaudePresetItems(); + + const existing = new Set(opts.existingNames || []); + return items.map((item) => ({ + ...item, + exists: existing.has(item.name), + })); +} diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 18f4c6d5..1df80da1 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -89,6 +89,16 @@ export function mergeWithDefaults(raw) { } } } + if (merged.capacityAdapter && typeof merged.capacityAdapter === "object") { + for (const capKey of Object.keys(merged.capacityAdapter)) { + const entry = merged.capacityAdapter[capKey]; + if (Array.isArray(entry?.models)) { + entry.models = entry.models.map((m) => + m === "oc/mimo-v2.5-free" ? "oc/mimo-v2.6-flash-free" : m + ); + } + } + } return merged; } diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index b5577cd0..8f46dfc1 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -469,7 +469,7 @@ export async function getUsageHistory(filter = {}) { function loadDaysInRange(adapter, maxDays) { if (maxDays == null) { - return adapter.all(`SELECT dateKey, data FROM usageDaily`); + return adapter.all(`SELECT dateKey, data FROM usageDaily ORDER BY dateKey ASC`); } const today = new Date(); const cutoff = new Date( @@ -479,7 +479,7 @@ function loadDaysInRange(adapter, maxDays) { ); const cutoffKey = `${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-${String(cutoff.getDate()).padStart(2, "0")}`; return adapter.all( - `SELECT dateKey, data FROM usageDaily WHERE dateKey >= ?`, + `SELECT dateKey, data FROM usageDaily WHERE dateKey >= ? ORDER BY dateKey ASC`, [cutoffKey], ); } @@ -771,8 +771,15 @@ export async function getUsageStats(period = "all") { } } - // Overlay precise lastUsed timestamps from history - const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0; + // Overlay precise lastUsed timestamps from history. + // ponytail: overlay scans only a recent window; entries older than that keep + // day-level lastUsed from usageDaily. Upgrade to a materialized per-key + // MAX(timestamp) table if exact old timestamps ever matter. + const OVERLAY_WINDOW_MS = 2 * 86400000; + const overlayCutoff = Math.max( + maxDays ? Date.now() - maxDays * 86400000 : 0, + Date.now() - OVERLAY_WINDOW_MS, + ); const histRows = db.all( `SELECT timestamp, provider, model, connectionId, apiKey, endpoint FROM usageHistory WHERE timestamp >= ?`, [new Date(overlayCutoff).toISOString()], @@ -1028,6 +1035,7 @@ export async function getChartData(period = "7d") { label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0, + requests: 0, })); const rows = db.all( @@ -1042,6 +1050,7 @@ export async function getChartData(period = "7d") { buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); buckets[idx].cost += r.cost || 0; + buckets[idx].requests += 1; } } return buckets; @@ -1061,6 +1070,7 @@ export async function getChartData(period = "7d") { label: labelFn(startTime + i * bucketMs), tokens: 0, cost: 0, + requests: 0, })); const rows = db.all( @@ -1076,15 +1086,48 @@ export async function getChartData(period = "7d") { ); buckets[idx].tokens += (r.promptTokens || 0) + (r.completionTokens || 0); buckets[idx].cost += r.cost || 0; + buckets[idx].requests += 1; } return buckets; } - const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; - const today = new Date(); const labelFn = (d) => d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + // "all" spans the earliest recorded day to today, one bucket per day. + if (period === "all") { + const dayRows = loadDaysInRange(db, null); + if (!dayRows.length) return []; + const dayMap = {}; + for (const r of dayRows) dayMap[r.dateKey] = parseJson(r.data, {}); + + const earliest = new Date(`${dayRows[0].dateKey}T00:00:00`); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const diffDays = Math.max( + 1, + Math.round((today - earliest) / 86400000) + 1, + ); + + return Array.from({ length: diffDays }, (_, i) => { + const d = new Date(earliest); + d.setDate(d.getDate() + i); + const dateKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dayData = dayMap[dateKey]; + return { + label: labelFn(d), + tokens: dayData + ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) + : 0, + cost: dayData ? dayData.cost || 0 : 0, + requests: dayData ? dayData.requests || 0 : 0, + }; + }); + } + + const bucketCount = period === "7d" ? 7 : period === "30d" ? 30 : 60; + const today = new Date(); + // Build map of dateKey → day data const dayRows = loadDaysInRange(db, bucketCount); const dayMap = {}; @@ -1101,6 +1144,7 @@ export async function getChartData(period = "7d") { ? (dayData.promptTokens || 0) + (dayData.completionTokens || 0) : 0, cost: dayData ? dayData.cost || 0 : 0, + requests: dayData ? dayData.requests || 0 : 0, }; }); } diff --git a/src/lib/mimoLoginSession.js b/src/lib/mimoLoginSession.js new file mode 100644 index 00000000..07d458f4 --- /dev/null +++ b/src/lib/mimoLoginSession.js @@ -0,0 +1,726 @@ +/** + * Server-side Xiaomi account session login (mimics MiMo Desktop's login surface). + * + * Flow (reverse-engineered from Desktop traffic / mimoAccount.js): + * 1. GET {mimo-server}/api/user/xiaomi/me -> 302 account /pass/serviceLogin?sid=mimopc&callback={sts} + * 2. GET account /pass/serviceLogin -> 302 /fe/service/login (the SPA) + * 3. Browser (via the src/proxy.js reverse proxy) completes login on the REAL + * page (password / whatever the page offers) — every account.xiaomi.com + * request passes through the proxy; Set-Cookie lands in OUR jar (which + * travels in the httpOnly 9r_mimo_login cookie between hops). + * 4. SPA navigates to the sts callback -> rewritten to /__mimo_login/mimo/*, + * middleware takes over and follows the chain server-side: + * sts -> Set-Cookie serviceToken -> me (200 JSON = logged in). + * 5. passToken/userId/cUserId read from the jar -> stored on the connection. + * + * Edge-safe: no Node-only APIs (used from both middleware and API routes). + */ + +const ACCOUNT_HOST = "account.xiaomi.com"; +export const SESSION_COOKIE = "9r_mimo_login"; +const SESSION_TTL_MS = 15 * 60 * 1000; +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 BROWSER_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +// Paths that belong to the 9router app itself — never proxy these upstream, +// even while a login session is active. Everything else is fair game: the +// login SPA hits evolving endpoints (/pass2/config, /v3/...), so a static +// allowlist rots fast. (Edge-safe: plain strings only.) +const APP_PREFIXES = [ + "/_next/", "/api/", "/dashboard", "/v1/", "/v1beta/", + "/login", "/landing", "/__mimo_login/", "/i18n/", "/icons/", "/providers/", +]; +const APP_FILES = new Set([ + "/favicon.svg", "/favicon.ico", "/file.svg", "/globe.svg", "/next.svg", + "/vercel.svg", "/window.svg", "/sw.js", "/robots.txt", "/manifest.webmanifest", +]); + +const APP_PATH_MATCHES = (pathname) => + APP_FILES.has(pathname) || + APP_PREFIXES.some((p) => pathname === p || pathname.startsWith(p.endsWith("/") ? p : p + "/")); + +const MIMO_BASES = { + 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", +}; +// Unknown/absent region falls back to SGP (the international/open cluster). +const DEFAULT_REGION = "sgp"; +// passToken-prefix -> last failure ts (60s backoff for the service exchange) +const _exchangeBackoff = new Map(); + +export function resolveMimoRegionBase(region) { + const r = String(region || "").toLowerCase(); + return MIMO_BASES[r] || MIMO_BASES[DEFAULT_REGION]; +} + +/** + * Browser-facing origin for this request. Prefer the Host header — request.url + * / nextUrl may carry the bind address (0.0.0.0), which must never leak into + * rewritten callbacks (start and proxy must agree on the exact same origin, + * and both see the same Host header). + */ +export function originOf(request) { + const proto = request.nextUrl?.protocol + || (request.headers?.get?.("x-forwarded-proto") || "http"); + const host = request.headers?.get?.("host") || request.nextUrl?.host; + return host ? `${proto}//${host}` : (request.nextUrl?.origin || "http://localhost:20131"); +} + +// The session (incl. the accumulated cookie jar) travels in the SESSION_COOKIE +// itself — Next runs route handlers and the proxy in separate bundles, so a +// module-level Map is NOT shared between them. Cookie-carried state works +// regardless of runtime topology. httpOnly + SameSite=Lax, TTL-bounded. + +export function beginSession(region) { + const normalizedRegion = String(region || "").toLowerCase(); + return { + state: crypto.randomUUID(), + region: normalizedRegion in MIMO_BASES ? normalizedRegion : DEFAULT_REGION, + proxyUrl: null, // resolved by the start route (env | local-probe for sgp | null) + jar: new Map(), // "name|domain|path" -> { name, value, domain, path } + status: "pending", + createdAt: Date.now(), + upstreamBase: resolveMimoRegionBase(region), + }; +} + +const KEEP_ON_OVERFLOW = /^(passToken|userId|cUserId|serviceToken|.*_serviceToken|.*_ph|.*_slh|deviceId)$/; +// Browser hard limit for one cookie value is 4096 bytes. base64url inflates +// ~1.34x, so the JSON payload must stay under ~2800 to be safe. +const COOKIE_JSON_BUDGET = 2800; + +function b64urlEncode(str) { + const bytes = new TextEncoder().encode(str); + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function b64urlDecode(s) { + let t = s.replace(/-/g, "+").replace(/_/g, "/"); + while (t.length % 4) t += "="; + const bin = atob(t); + return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))); +} + +export function encodeSessionCookie(sess) { + const entries = [...sess.jar.values()].map((c) => [c.name, c.value, c.domain, c.path]); + const base = { s: sess.state, r: sess.region, t: sess.createdAt, p: sess.proxyUrl || "" }; + let payload = JSON.stringify({ ...base, j: entries }); + if (payload.length > COOKIE_JSON_BUDGET) { + // Cookie budget: drop everything but identity/session essentials. + payload = JSON.stringify({ ...base, j: entries.filter(([name]) => KEEP_ON_OVERFLOW.test(name)) }); + } + return `v2.${b64urlEncode(payload)}`; +} + +export function decodeSessionCookie(value) { + if (!value) return null; + let payload; + try { + if (value.startsWith("v2.")) { + payload = JSON.parse(b64urlDecode(value.slice(3))); + } else if (value.startsWith("v1.")) { + payload = JSON.parse(decodeURIComponent(value.slice(3))); // legacy + } else { + return null; + } + } catch { + return null; + } + if (!payload || typeof payload.t !== "number") return null; + if (Date.now() - payload.t > SESSION_TTL_MS) return null; + const jar = new Map(); + for (const raw of payload.j || []) { + if (!Array.isArray(raw) || raw.length < 4) continue; + const [name, val, domain, path] = raw; + jar.set(`${name}|${domain}|${path}`, { name, value: val, domain, path }); + } + return { + state: payload.s, + region: payload.r in MIMO_BASES ? payload.r : DEFAULT_REGION, + proxyUrl: typeof payload.p === "string" && payload.p ? payload.p : null, + jar, + status: "pending", + createdAt: payload.t, + upstreamBase: resolveMimoRegionBase(payload.r), + }; +} + +/** Read the session straight from an incoming request (any runtime). */ +export function sessionFromRequest(request) { + const raw = request.cookies?.get?.(SESSION_COOKIE)?.value + ?? parseCookieHeader(request.headers?.get?.("cookie"))?.[SESSION_COOKIE]; + return decodeSessionCookie(raw || null); +} + +function parseCookieHeader(header) { + if (!header) return null; + const out = {}; + for (const part of header.split(";")) { + const i = part.indexOf("="); + if (i < 0) continue; + out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); + } + return out; +} + +/** Append the re-encoded session to any Response (proxy writes go through here). */ +export function attachSessionCookie(response, sess) { + const headers = new Headers(response.headers); + headers.append( + "Set-Cookie", + `${SESSION_COOKIE}=${encodeSessionCookie(sess)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, + ); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export function clearedSessionCookie() { + return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +// ---------- cookie jar (server-side) ---------- + +function jarKey(c) { + return `${c.name}|${c.domain}|${c.path}`; +} + +function domainMatch(cookieDomain, host) { + const d = String(cookieDomain || "").replace(/^\./, "").toLowerCase(); + const h = String(host || "").toLowerCase(); + return h === d || h.endsWith("." + d); +} + +/** Parse one Set-Cookie header value. Returns { cookie, expired } or null. */ +function parseSetCookie(raw, requestUrl) { + if (!raw) return null; + const parts = raw.split(";"); + const nv = /^([^=]+)=([\s\S]*)$/.exec(parts[0].trim()); + if (!nv) return null; + const name = nv[1].trim(); + const value = (nv[2] || "").trim(); + const url = new URL(requestUrl); + const cookie = { + name, + value, + domain: url.hostname, + path: (url.pathname || "/").replace(/[^/]*$/, "") || "/", + }; + let expired = value === "EXPIRED"; + for (let i = 1; i < parts.length; i++) { + const f = parts[i].trim(); + const eq = f.indexOf("="); + const k = (eq >= 0 ? f.slice(0, eq) : f).trim().toLowerCase(); + const v = eq >= 0 ? f.slice(eq + 1).trim() : ""; + if (k === "domain" && v) cookie.domain = v.replace(/^\./, ""); + else if (k === "path" && v) cookie.path = v; + else if (k === "expires") { + if (/expired/i.test(v)) expired = true; + else { + const t = Date.parse(v); + if (!Number.isNaN(t) && t <= Date.now()) expired = true; + } + } else if (k === "max-age" && Number(v) <= 0) expired = true; + } + return { cookie, expired }; +} + +export function absorbSetCookies(sess, res, requestUrl) { + for (const raw of res.headers.getSetCookie?.() || []) { + const parsed = parseSetCookie(raw, requestUrl); + if (!parsed) continue; + const key = jarKey(parsed.cookie); + if (parsed.expired || !parsed.cookie.value) sess.jar.delete(key); + else sess.jar.set(key, parsed.cookie); + } +} + +function cookieHeaderFor(sess, targetUrl) { + const u = new URL(targetUrl); + const out = []; + for (const c of sess.jar.values()) { + if (!domainMatch(c.domain, u.hostname)) continue; + if (!(u.pathname || "/").startsWith(c.path)) continue; + out.push(`${c.name}=${c.value}`); + } + return out.join("; "); +} + +/** Extract identity cookies from the account host jar. */ +export function readSessionIdentity(sess) { + const pick = (name) => { + for (const c of sess.jar.values()) { + if (c.name === name && domainMatch(c.domain, ACCOUNT_HOST)) return c.value; + } + return null; + }; + const passToken = pick("passToken"); + if (!passToken || passToken === "EXPIRED") return null; + return { passToken, userId: pick("userId"), cUserId: pick("cUserId") }; +} + +/** + * Redeem the captured passToken for a mimo-server service session using the + * BATTLE-TESTED desktop handshake (serviceLogin sid=mimopc + clientSign) that + * powers every existing CN desktop connection — instead of the interactive + * /api/sts webview callback, which rejects server-side calls (401). + * Merges the resulting serviceCookie into sess.jar, then confirms via me. + * @returns {Promise} true when the me probe answers 200 (logged in). + */ +export async function ensureServiceSession(sess) { + const id = readSessionIdentity(sess); + if (!id) return false; + const T = () => new Date().toISOString().slice(11, 23); + const log = (m) => console.log(`${T()} [mimo-login][exchange] ${m}`); + // Backoff: a failed full 5-step chain must not re-run on every 2.5s poll. + const bkKey = id.passToken.slice(0, 24); + const lastFail = _exchangeBackoff.get(bkKey); + if (lastFail && Date.now() - lastFail < 60_000) { + log("exchange in backoff (60s), skip"); + return false; + } + try { + const mod = await import("../../open-sse/shared/mimoAccount.js"); + const proxyOptions = sess.proxyUrl ? { enabled: true, url: sess.proxyUrl } : null; + log(`exchanging passToken (region=${sess.region}, egress=${sess.proxyUrl || "direct"}) ...`); + const serviceCookie = await mod.getMimoAccountCookie( + { + region: sess.region, + mimoPassToken: id.passToken, + mimoUserId: id.userId, + mimoCUserId: id.cUserId, + }, + proxyOptions, + ); + if (!serviceCookie) { + log("exchange failed: no service cookie"); + _exchangeBackoff.set(bkKey, Date.now()); + return false; + } + // Flatten "a=b; c=d" into the jar under the mimo-server host. + _exchangeBackoff.delete(bkKey); + const host = new URL(sess.upstreamBase).hostname; + for (const pair of String(serviceCookie).split(";")) { + const eq = pair.indexOf("="); + if (eq <= 0) continue; + const name = pair.slice(0, eq).trim(); + const value = pair.slice(eq + 1).trim(); + if (!name || !value) continue; + sess.jar.set(`${name}|${host}|/`, { name, value, domain: host, path: "/" }); + } + log(`serviceCookie merged, jar=[${[...sess.jar.keys()].map((k) => k.split("|")[0]).join(",").slice(0, 160)}]`); + + const meUrl = `${sess.upstreamBase}/api/user/xiaomi/me`; + const res = await fetchUpstream( + sess, + meUrl, + { method: "GET", headers: { "User-Agent": API_UA } }, + cookieHeaderFor(sess, meUrl), + ); + absorbSetCookies(sess, res, meUrl); + log(`me confirm http=${res.status}`); + return res.status === 200; + } catch (e) { + log(`exchange error: ${e?.message || e}`); + _exchangeBackoff.set(bkKey, Date.now()); + return false; + } +} + +// ---------- URL rewriting (mimo-server base <-> /__mimo_login/mimo) ---------- + +function encodingVariants(s) { + // The callback/followup params appear raw, url-encoded once, twice... + const out = [s]; + let cur = s; + for (let i = 0; i < 3; i++) { + cur = encodeURIComponent(cur); + out.push(cur); + } + return out; +} + +/** + * Rewrite mimo-server base URLs (any encoding depth) in a string. + * direction "toProxy": mimo-base -> `${origin}/__mimo_login/mimo` + * direction "toUpstream": reverse. + */ +export function rewriteMimoBases(text, direction, origin, upstreamBase = null) { + if (!text) return text; + let out = String(text); + const proxyBase = `${origin}/__mimo_login/mimo`; + const bases = [...new Set(Object.values(MIMO_BASES))]; + const fromBases = direction === "toProxy" ? bases : [proxyBase]; + const toBase = direction === "toProxy" ? proxyBase : (upstreamBase || bases[0]); + for (const fromBase of fromBases) { + const variants = [ + fromBase, + fromBase.replace("https://", "http://"), + fromBase.replace(/^https?:/, ""), + fromBase.replaceAll("/", "\\/"), + fromBase.replace("https://", "http://").replaceAll("/", "\\/"), + ]; // + protocol-relative + json escaped slashes + const toVariants = [ + toBase, + toBase.replace("https://", "http://"), + toBase, + toBase.replaceAll("/", "\\/"), + toBase.replaceAll("/", "\\/"), + ]; + for (let vIdx = 0; vIdx < variants.length; vIdx++) { + const fromList = encodingVariants(variants[vIdx]); + const toList = encodingVariants(toVariants[vIdx]); + for (let i = 0; i < fromList.length; i++) { + out = out.split(fromList[i]).join(toList[i]); + } + } + } + + // account.xiaomi.com absolute URLs: keep navigation (e.g. identity/authStart + // 2FA prompts) on our origin — every path on that host is already proxied + // natively. Reverse applies to request URLs/bodies before hitting upstream. + const acctOrigins = ["https://account.xiaomi.com", "http://account.xiaomi.com", "//account.xiaomi.com"]; + if (direction === "toProxy") { + const toL = encodingVariants(origin); + for (const a of acctOrigins) { + const fromL = encodingVariants(a); + for (let i = 0; i < fromL.length; i++) out = out.split(fromL[i]).join(toL[i]); + } + } else { + const toA = encodingVariants("https://account.xiaomi.com"); + const toAEscaped = encodingVariants("https:\\/\\/account.xiaomi.com"); + const fromL = encodingVariants(origin); + const fromLProto = encodingVariants(origin.replace(/^https?:/, "")); + const fromLEscaped = encodingVariants(origin.replaceAll("/", "\\/")); + for (let i = 0; i < fromL.length; i++) { + out = out.split(fromL[i]).join(toA[i]); + out = out.split(fromLProto[i]).join(toA[i]); + out = out.split(fromLEscaped[i]).join(toAEscaped[i]); + } + } + return out; +} + +/** Reverse the proxy rewrite in an incoming URL before hitting upstream. */ +export function deRewriteUrl(rawUrl, origin, upstreamBase = null) { + return rewriteMimoBases(rawUrl, "toUpstream", origin, upstreamBase); +} + +export function isAccountProxyPath(pathname) { + // Inverted: proxy EVERYTHING except the app's own paths (only consulted + // while a login session cookie/param is present). + return !APP_PATH_MATCHES(pathname); +} + +export function isMimoTakeoverPath(pathname) { + return pathname.startsWith("/__mimo_login/mimo/"); +} + +/** Map /__mimo_login/mimo/* back to the upstream mimo-server path. */ +export function takeoverUpstreamPath(pathname) { + return pathname.slice("/__mimo_login/mimo".length) || "/"; +} + +// ---------- upstream fetch with optional egress proxy ---------- +// +// The login page's feature set (Google sign-in etc.) is geo-decided by the +// egress IP of THESE requests. The proxy applies ONLY when the user picked +// region=sgp — the start route resolves it (via local-port probe) and rides it +// on the session cookie as sess.proxyUrl. CN sessions never proxy (null -> direct). + +let _pafPromise = null; +let _socksPromise = null; + +/** fetch-compatible wrapper over socks-proxy-agent (undici ProxyAgent has no socks support). */ +async function socksFetch(url, init, proxyUrl) { + if (!_socksPromise) { + _socksPromise = import("socks-proxy-agent") + .then((m) => m.SocksProxyAgent || m.default?.SocksProxyAgent || m.default) + .catch((e) => { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] socks-proxy-agent unavailable:`, e?.message || e); + return null; + }); + } + const SocksProxyAgent = await _socksPromise; + if (!SocksProxyAgent) throw new Error("socks agent unavailable"); + + const nodeUrl = new URL(url); + // Literal specifiers on both branches — webpack forbids fully dynamic import(). + const protoMod = nodeUrl.protocol === "http:" ? await import("node:http") : await import("node:https"); + const lib = protoMod.default ?? protoMod; + const { Readable } = await import("node:stream"); + + let headers = {}; + const raw = init?.headers; + if (raw instanceof Headers) for (const [k, v] of raw) headers[k] = v; + else if (raw) headers = { ...raw }; + + let body = init?.body; + if (body && typeof body !== "string" && !Buffer.isBuffer(body)) body = Buffer.from(body); + if (body) headers["content-length"] = String(Buffer.byteLength(body)); + + const agent = new SocksProxyAgent(proxyUrl); + return new Promise((resolve, reject) => { + const req = lib.request( + nodeUrl, + { method: init?.method || "GET", agent, headers, timeout: 20000 }, + (res) => { + const outHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers || {})) { + if (Array.isArray(v)) v.forEach((x) => outHeaders.append(k, String(x))); + else if (v != null) outHeaders.set(k, String(v)); + } + resolve(new Response(Readable.toWeb(res), { status: res.statusCode || 200, headers: outHeaders })); + }, + ); + const signal = init?.signal; + if (signal) { + if (signal.aborted) req.destroy(new Error("aborted")); + else signal.addEventListener("abort", () => req.destroy(new Error("aborted")), { once: true }); + } + req.on("timeout", () => req.destroy(new Error("socks fetch timeout"))); + req.on("error", reject); + if (body) req.write(body); + req.end(); + }); +} + +async function loginFetch(url, init, sessionProxyUrl = null) { + if (!sessionProxyUrl) return fetch(url, init); // direct — no agent machinery needed + const proxyOptions = { enabled: true, url: sessionProxyUrl }; + try { + if (/^socks/i.test(sessionProxyUrl)) return await socksFetch(url, init, sessionProxyUrl); + if (!_pafPromise) { + _pafPromise = import("../../open-sse/utils/proxyFetch.js") + .then((m) => m.proxyAwareFetch) + .catch((e) => { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxyAwareFetch unavailable (runtime?), direct only:`, e?.message || e); + return null; + }); + } + const paf = await _pafPromise; + if (paf) return await paf(url, init, proxyOptions); + } catch (e) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxied fetch failed, falling back to direct:`, e?.message || e); + } + return fetch(url, init); +} + +/** Public alias — start/status routes share the same egress path. */ +export const loginUpstreamFetch = (url, init, sess = null) => loginFetch(url, init, sess?.proxyUrl || null); + +// ---------- upstream proxying ---------- + +async function fetchUpstream(sess, url, init, cookieValue) { + const headers = new Headers(init.headers); + if (cookieValue) headers.set("Cookie", cookieValue); + return loginFetch(url, { ...init, headers, redirect: "manual", signal: AbortSignal.timeout(20000) }, sess?.proxyUrl || null); +} + +function browserCookieHeader(req) { + return req.headers.get("cookie") || ""; +} + +// Headers never forwarded to the upstream account host. +const STRIP_UPSTREAM_HEADERS = new Set([ + "host", "cookie", "connection", "content-length", "transfer-encoding", + "keep-alive", "upgrade", "expect", "proxy-connection", + // Credentials for 9router itself — must never reach a third-party upstream. + "authorization", "proxy-authorization", +]); + +/** + * Proxy one account.xiaomi.com request from the browser. + * Captures Set-Cookie into the server jar, strips frame-blocking headers, + * rewrites Location/body mimo-base references back into the proxy space. + */ +export async function proxyAccountRequest(sess, req, origin) { + const u = new URL(req.url); + u.searchParams.delete("__9r_sess"); // never forward our session to upstream + const upstream = new URL(u.pathname + u.search, `https://${ACCOUNT_HOST}`); + + // The SPA's callback params were rewritten to our proxy — undo before upstream + // so signature (_sign) validation on the real callback still passes. + const upstreamStr = deRewriteUrl(upstream.toString(), origin, sess.upstreamBase); + + const headers = {}; + // Forward EVERYTHING the browser sent (minus hop-by-hop + host/cookie) so no + // custom SDK header gets silently dropped. Then fix up cross-origin fields: + // the SPA talks to account.xiaomi.com, so Origin/Referer must be rewritten + // from our origin to the account origin — keeping the original path/query + // (a wrong Referer path trips Xiaomi's login risk control, error 10025). + for (const [k, v] of req.headers) { + if (STRIP_UPSTREAM_HEADERS.has(k.toLowerCase())) continue; + headers[k] = v; + } + const ourOrigin = origin; + const fixOriginUrl = (val) => { + if (!val) return null; + try { + const u = new URL(val); + if (u.origin === ourOrigin) { + u.protocol = "https:"; + u.host = ACCOUNT_HOST; + return u.toString(); + } + if (u.hostname === ACCOUNT_HOST) return u.toString(); + return null; // some other origin — let our forced account values win + } catch { return null; } + }; + const rawOrigin = headers.origin || headers.Origin || null; + delete headers.origin; + delete headers.Origin; + // Real browsers only send Origin on XHR POSTs — preserve that shape, but + // point it at the account host (never leak localhost upstream). + if (rawOrigin) headers.Origin = `https://${ACCOUNT_HOST}`; + const fixedReferer = fixOriginUrl(headers.referer || headers.Referer); + headers.Referer = fixedReferer + ? deRewriteUrl(fixedReferer, origin, sess.upstreamBase) + : `https://${ACCOUNT_HOST}/fe/service/login`; + delete headers.referer; + + const init = { method: req.method || "GET", headers }; + if (init.method !== "GET" && init.method !== "HEAD") { + let buf = Buffer.from(await req.arrayBuffer()); + // The SPA reads callback params from location.search (which we rewrote to + // our origin) and can echo them in the POST BODY — reverse that too, or + // Xiaomi rejects with 10025 "Callback连接不合法". + const ctBody = String(headers["Content-Type"] || headers["content-type"] || ""); + if (buf.length && /urlencoded|json|text/i.test(ctBody)) { + const before = buf.toString("utf8"); + const after = rewriteMimoBases(before, "toUpstream", origin, sess.upstreamBase); + if (after !== before) { + buf = Buffer.from(after, "utf8"); + headers["Content-Length"] = String(buf.length); + } else if (/__mimo_login|localhost/.test(before)) { + // Anomaly: a local callback shape we cannot rewrite — must never reach Xiaomi. + console.log(`${new Date().toISOString().slice(11, 23)} [mimo-login] body STILL local, not matchable: ${before.slice(0, 200)}`); + } + } + init.body = buf; + } + + // Follow same-host redirects server-side (they carry Set-Cookie we must keep). + let current = upstreamStr; + let res = null; + const requestCookies = cookieHeaderFor(sess, current) || browserCookieHeader(req); + for (let hop = 0; hop < 8; hop++) { + res = await fetchUpstream(sess, current, hop === 0 ? init : { ...init, body: undefined }, requestCookies); + absorbSetCookies(sess, res, current); + const loc = res.headers.get("location"); + if (res.status >= 300 && res.status < 400 && loc) { + const nextAbs = new URL(loc, current); + if (nextAbs.hostname === ACCOUNT_HOST) { + current = nextAbs.toString(); + continue; + } + // Cross-host redirect to mimo-server: rewrite into our takeover space so + // the browser stays on our origin. + if (/mimo-server-(cn|sgp)\.xiaomimimo\.com/.test(nextAbs.hostname)) { + const proxiedLoc = rewriteMimoBases(nextAbs.toString(), "toProxy", origin); + return new Response(null, { status: res.status, headers: { Location: proxiedLoc, "Cache-Control": "no-store" } }); + } + // Any other host: pass through. + return new Response(null, { status: res.status, headers: { Location: loc } }); + } + break; + } + + return buildBrowserResponse(sess, res, origin, u.pathname, current); +} + +function buildBrowserResponse(sess, res, origin, reqPath = "", upstreamUrl = "") { + const outHeaders = new Headers(); + const pass = ["content-type", "cache-control", "etag", "last-modified", "date"]; + for (const h of pass) { + const v = res.headers.get(h); + if (v) outHeaders.set(h, v); + } + // Allow embedding in our modal (upstream often sends X-Frame-Options). + outHeaders.delete("x-frame-options"); + outHeaders.delete("content-security-policy"); + outHeaders.delete("content-security-policy-report-only"); + outHeaders.set("Cache-Control", "no-store"); + // Deliberately NOT forwarding upstream Set-Cookie to the browser: every + // proxied request already strips the browser Cookie header, so upstream + // auth lives only in the server-side jar. Replayed cookies would land on + // our origin's jar (some without HttpOnly → readable by any script here). + + const ctType = res.headers.get("content-type") || ""; + const isText = /text\/|javascript|json/.test(ctType); + if (!isText) return new Response(res.body, { status: res.status, headers: outHeaders }); + + return res.text().then((rawBody) => { + // JSON allows \/ as an escaped slash — Xiaomi backends (PHP-style) emit + // "https:\/\/mimo-server..." which defeats scheme-based string matching + // AND the SPA JSON.parses it back to a real URL (this leaked the sts + // callback straight to the browser -> cross-origin 401). Unescaping \/ to + // / inside JSON string values is semantics-preserving and stays valid. + const body = /json/.test(ctType) ? rawBody.replaceAll("\\/", "/") : rawBody; + // Surface upstream API errors (Xiaomi wraps JSON as &&&START&&&{code:...}). + if (/^\/(pass|sts)/.test(reqPath) || res.status >= 400) { + const m = body.match(/"code"\s*:\s*(-?\d+)/); + if ((m && m[1] !== "0") || res.status >= 400) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] upstream ${reqPath} http=${res.status} code=${m ? m[1] : "?"} | url=${upstreamUrl.slice(0, 180)} | ${body.replace(/\s+/g, " ").slice(0, 160)}`); + } + } + const rewritten = rewriteMimoBases(body, "toProxy", origin); + return new Response(rewritten, { status: res.status, headers: outHeaders }); + }); +} + +/** + * Take over the mimo-server tail of the flow (sts -> me) server-side. + * Runs the whole redirect chain, absorbs cookies, verifies me=logged-in. + */ +export async function runTakeover(sess, upstreamUrl, origin) { + const T = () => new Date().toISOString().slice(11, 23); + const log = (m) => console.log(`${T()} [mimo-login][takeover] ${m}`); + const idSnap = () => { + const id = readSessionIdentity(sess); + const names = [...sess.jar.keys()].map((k) => k.split("|")[0]).join(","); + return `passToken=${id ? "Y" : "N"} jar=[${names.slice(0, 160)}]`; + }; + log(`sts-nav url=${upstreamUrl.slice(0, 160)} origin=${origin} | ${idSnap()}`); + const id = readSessionIdentity(sess); + // AUTHORIZATION COMPLETION = passToken captured (that IS the credential the + // connection persists for the account route). The serviceToken exchange + // (weekly quota) is intentionally NOT part of completion — its API moved; + // ensureServiceSession stays exported for when that gets re-wired. + if (id) { + sess.status = "done"; + return donePage(); + } + return pendingPage(); +} + +function htmlPage(title, lines, autoClose = false) { + const body = `${title} + +

    ${title}

    ${lines.map((l) => `

    ${l}

    `).join("")}
    + +`; + return new Response(body, { status: 200, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } }); +} + +function donePage() { + return htmlPage("登录成功 ✅", ["账号会话已捕获,可以关闭此窗口。", "回到 9router 弹窗继续。"], true); +} + +function pendingPage() { + return htmlPage("登录未完成", ["未检测到有效会话,请重试。"]); +} + +export const __test__ = { STRIP_UPSTREAM_HEADERS, buildBrowserResponse }; diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 77cd3c13..cd35d331 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -35,6 +35,9 @@ export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini // of attempting to silently rotate. export const QODER_CONFIG = { ...PROVIDER_OAUTH["qoder"] }; +// Qoder CN (qoder.com.cn) — same device flow as intl Qoder, CN endpoints. +export const QODER_CN_CONFIG = { ...PROVIDER_OAUTH["qoder-cn"] }; + // iFlow OAuth Configuration (Authorization Code) export const IFLOW_CONFIG = { ...PROVIDER_OAUTH["iflow"] }; @@ -219,6 +222,7 @@ export const PROVIDERS = { CODEX: "codex", GEMINI: "gemini-cli", QODER: "qoder", + QODER_CN: "qoder-cn", IFLOW: "iflow", ANTIGRAVITY: "antigravity", OPENAI: "openai", diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index 8ba6c8cb..86ed5338 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -12,6 +12,7 @@ import geminiCli from "./gemini-cli.js"; import antigravity from "./antigravity.js"; import iflow from "./iflow.js"; import qoder from "./qoder.js"; +import qoderCn from "./qoder-cn.js"; import github from "./github.js"; import kiro from "./kiro.js"; import cursor from "./cursor.js"; @@ -37,6 +38,7 @@ const PROVIDERS = { antigravity, iflow, qoder, + "qoder-cn": qoderCn, github, kiro, cursor, diff --git a/src/lib/oauth/providers/qoder-cn.js b/src/lib/oauth/providers/qoder-cn.js new file mode 100644 index 00000000..1347fe2d --- /dev/null +++ b/src/lib/oauth/providers/qoder-cn.js @@ -0,0 +1,5 @@ +import { QODER_CN_CONFIG } from "../constants/oauth.js"; +import { createQoderProvider } from "./qoder.js"; + +// Qoder CN (qoder.com.cn) — same device flow as intl Qoder, CN endpoints. +export default createQoderProvider(QODER_CN_CONFIG); diff --git a/src/lib/oauth/providers/qoder.js b/src/lib/oauth/providers/qoder.js index fa46a92d..437d9587 100644 --- a/src/lib/oauth/providers/qoder.js +++ b/src/lib/oauth/providers/qoder.js @@ -1,102 +1,111 @@ import { QODER_CONFIG } from "../constants/oauth.js"; -const qoder = { - config: QODER_CONFIG, - flowType: "device_code", - // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated - // locally, the user lands on qoder.com/device/selectAccounts in the - // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. - requestDeviceCode: async (config) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const flow = new QoderService().initiateDeviceFlow(); - // Match the device_code shape the rest of the OAuthModal expects - // (device_code, user_code, verification_uri[_complete], interval). - // The poll endpoint identifies us by nonce+verifier, not by a - // server-issued device_code, so we plumb our own values through: - // device_code = nonce (modal forwards as deviceCode on poll) - // codeVerifier = our PKCE verifier (route forwards as codeVerifier) - return { - device_code: flow.nonce, - user_code: flow.nonce.slice(0, 8).toUpperCase(), - verification_uri: config.loginUrl, - verification_uri_complete: flow.verificationUriComplete, - expires_in: 300, - interval: 2, - codeVerifier: flow.codeVerifier, - _qoderNonce: flow.nonce, - _qoderMachineId: flow.machineId, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const svc = new QoderService(); - const nonce = deviceCode || extraData?._qoderNonce; - const verifier = codeVerifier || extraData?._qoderVerifier; - if (!nonce || !verifier) { +/** + * Build a Qoder device-code provider for a region. `config` is the registry + * oauth block (see open-sse/providers/registry/qoder.js / qoder-cn.js), which + * carries the region's login/deviceToken/userInfo URLs. The device flow is + * identical across regions — only the hosts differ. + */ +export function createQoderProvider(config) { + return { + config, + flowType: "device_code", + // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated + // locally, the user lands on qoder.com[-cn]/device/selectAccounts in the + // browser, and we poll the region's deviceToken endpoint until a `dt-...` + // token appears. + requestDeviceCode: async (cfg) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const flow = new QoderService(cfg).initiateDeviceFlow(); + // Match the device_code shape the rest of the OAuthModal expects + // (device_code, user_code, verification_uri[_complete], interval). + // The poll endpoint identifies us by nonce+verifier, not by a + // server-issued device_code, so we plumb our own values through: + // device_code = nonce (modal forwards as deviceCode on poll) + // codeVerifier = our PKCE verifier (route forwards as codeVerifier) return { - ok: false, - data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, + device_code: flow.nonce, + user_code: flow.nonce.slice(0, 8).toUpperCase(), + verification_uri: cfg.loginUrl, + verification_uri_complete: flow.verificationUriComplete, + expires_in: 300, + interval: 2, + codeVerifier: flow.codeVerifier, + _qoderNonce: flow.nonce, + _qoderMachineId: flow.machineId, }; - } - let result; - try { - result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); - } catch (err) { + }, + pollToken: async (cfg, deviceCode, codeVerifier, extraData) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const svc = new QoderService(cfg); + const nonce = deviceCode || extraData?._qoderNonce; + const verifier = codeVerifier || extraData?._qoderVerifier; + if (!nonce || !verifier) { + return { + ok: false, + data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, + }; + } + let result; + try { + result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); + } catch (err) { + return { + ok: false, + data: { error: "poll_failed", error_description: err.message }, + }; + } + if (result.status === "pending") { + return { ok: false, data: { error: "authorization_pending" } }; + } + // Best-effort profile lookup so we have a name/email to display. + const userInfo = await svc.fetchUserInfo(result.accessToken); + // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, + // which already falls back to "now + 30 days" when the upstream + // omits expiry. Floor to a sane minimum (1 day) so a stale or + // skewed upstream timestamp doesn't truncate the stored token below + // something useful. + const minSeconds = 24 * 60 * 60; + const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); + const expiresIn = Math.max(minSeconds, remainingSeconds); return { - ok: false, - data: { error: "poll_failed", error_description: err.message }, + ok: true, + data: { + access_token: result.accessToken, + refresh_token: result.refreshToken, + expires_in: expiresIn, + _qoderUserId: result.userId, + _qoderMachineId: extraData?._qoderMachineId || "", + _qoderName: userInfo.name, + _qoderEmail: userInfo.email, + _qoderOrganizationId: userInfo.organizationId, + }, }; - } - if (result.status === "pending") { - return { ok: false, data: { error: "authorization_pending" } }; - } - // Best-effort profile lookup so we have a name/email to display. - const userInfo = await svc.fetchUserInfo(result.accessToken); - // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, - // which already falls back to "now + 30 days" when the upstream - // omits expiry. Floor to a sane minimum (1 day) so a stale or - // skewed upstream timestamp doesn't truncate the stored token below - // something useful. - const minSeconds = 24 * 60 * 60; - const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); - const expiresIn = Math.max(minSeconds, remainingSeconds); - return { - ok: true, - data: { - access_token: result.accessToken, - refresh_token: result.refreshToken, - expires_in: expiresIn, - _qoderUserId: result.userId, - _qoderMachineId: extraData?._qoderMachineId || "", - _qoderName: userInfo.name, - _qoderEmail: userInfo.email, - _qoderOrganizationId: userInfo.organizationId, - }, - }; - }, - mapTokens: (tokens) => { - const rawEmail = (tokens._qoderEmail || "").trim(); - const displayName = (tokens._qoderName || "").trim() || null; - const userId = tokens._qoderUserId || ""; - // Dedup in createProviderConnection requires a non-empty email. When - // fetchUserInfo silently fails (returns ""), fall back to a stable - // synthetic identifier derived from userId so re-logins update the - // existing row instead of accumulating "Account N" duplicates. - const email = rawEmail || (userId ? `qoder-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - email, - displayName, - providerSpecificData: { - authMethod: "device", - userId, - machineId: tokens._qoderMachineId || "", - organizationId: tokens._qoderOrganizationId || "", - }, - }; - }, -}; + }, + mapTokens: (tokens) => { + const rawEmail = (tokens._qoderEmail || "").trim(); + const displayName = (tokens._qoderName || "").trim() || null; + const userId = tokens._qoderUserId || ""; + // Dedup in createProviderConnection requires a non-empty email. When + // fetchUserInfo silently fails (returns ""), fall back to a stable + // synthetic identifier derived from userId so re-logins update the + // existing row instead of accumulating "Account N" duplicates. + const email = rawEmail || (userId ? `qoder-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + email, + displayName, + providerSpecificData: { + authMethod: "device", + userId, + machineId: tokens._qoderMachineId || "", + organizationId: tokens._qoderOrganizationId || "", + }, + }; + }, + }; +} -export default qoder; +export default createQoderProvider(QODER_CONFIG); diff --git a/src/lib/oauth/services/qoder.js b/src/lib/oauth/services/qoder.js index 861a99b0..36d73735 100644 --- a/src/lib/oauth/services/qoder.js +++ b/src/lib/oauth/services/qoder.js @@ -52,6 +52,27 @@ async function fetchWithTimeout(url, init = {}) { } export class QoderService { + /** + * Region-aware device flow. Pass an oauth config block (registry oauth → + * PROVIDER_OAUTH) to hit the CN site; without one, the intl endpoints are + * used. Only the hostnames differ between regions — the flow is identical. + */ + constructor(config = {}) { + this.config = config; + } + + loginUrl() { + return this.config.loginUrl || QODER_LOGIN_URL; + } + + deviceTokenUrl() { + return this.config.deviceTokenUrl || QODER_DEVICE_TOKEN_URL; + } + + userInfoUrl() { + return this.config.userInfoUrl || QODER_USERINFO_URL; + } + /** * Generate a PKCE verifier + S256 challenge pair. * Uses 32 random bytes (matches qodercli/Veria). @@ -79,7 +100,7 @@ export class QoderService { }); return { - verificationUriComplete: `${QODER_LOGIN_URL}?${params.toString()}`, + verificationUriComplete: `${this.loginUrl()}?${params.toString()}`, codeVerifier: verifier, nonce, machineId, @@ -98,7 +119,7 @@ export class QoderService { if (!nonce || !codeVerifier) { throw new Error("pollDeviceToken: missing nonce or code verifier"); } - const url = `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`; + const url = `${this.deviceTokenUrl()}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`; const response = await fetchWithTimeout(url, { method: "GET", @@ -155,7 +176,7 @@ export class QoderService { */ async fetchUserInfo(accessToken) { try { - const response = await fetchWithTimeout(QODER_USERINFO_URL, { + const response = await fetchWithTimeout(this.userInfoUrl(), { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, diff --git a/src/proxy.js b/src/proxy.js index 566dd14c..b42dd193 100644 --- a/src/proxy.js +++ b/src/proxy.js @@ -1,6 +1,70 @@ -import { proxy as dashboardProxy } from "./dashboardGuard"; +import { proxy as dashboardProxy, isAuthenticated } from "./dashboardGuard"; +import { + sessionFromRequest, + isAccountProxyPath, + isMimoTakeoverPath, + takeoverUpstreamPath, + proxyAccountRequest, + runTakeover, + attachSessionCookie, + originOf, +} from "./lib/mimoLoginSession"; export default async function proxy(request) { + // Xiaomi account session-login proxy (src/lib/mimoLoginSession.js). + // Session state (region + accumulated cookie jar) travels in the httpOnly + // 9r_mimo_login cookie — route handlers and this proxy run in separate + // bundles, so module-level maps are NOT shared. The cookie is only set by + // the auth-gated login/start route, and the branch below ALSO requires a + // valid dashboard session: a forged 9r_mimo_login cookie (client-controlled + // header, unsigned payload) must never turn the app into an unauthenticated + // forwarder. No URL-carried session — it would leak the jar via history/logs/Referer. + const cookies = request.headers.get("cookie") || ""; + const hasSessionCookie = cookies.includes("9r_mimo_login="); + const { pathname } = request.nextUrl; + if (hasSessionCookie && !(await isAuthenticated(request))) { + // Forged or stale session cookie without dashboard auth — drop it early. + const res = await dashboardProxy(request); + const headers = new Headers(res.headers); + headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); + } + if (!hasSessionCookie && /^\/(fe\/|pass)/.test(pathname) && !pathname.startsWith("/_next")) { + // Anomaly: a login-flow XHR arrived without the session — the classic + // cause of silent SPA "Something went wrong" 404s. Narrow to login paths + // so unrelated unknown routes don't spam this. + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] no-session ${pathname} (cookie header: ${cookies ? cookies.slice(0, 80) : ""})`); + } + if (hasSessionCookie) { + const sess = sessionFromRequest(request); + if (sess) { + const origin = originOf(request); + try { + if (isMimoTakeoverPath(pathname)) { + const upstreamUrl = `${sess.upstreamBase}${takeoverUpstreamPath(pathname)}${request.nextUrl.search || ""}`; + return attachSessionCookie(await runTakeover(sess, upstreamUrl, origin), sess); + } + if (isAccountProxyPath(pathname)) { + return attachSessionCookie(await proxyAccountRequest(sess, request, origin), sess); + } + } catch (e) { + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] proxy error:`, e?.message || e); + return new Response("mimo login proxy error", { status: 502 }); + } + } else { + // Anomaly (should not happen in a healthy flow): cookie present but unparseable. + console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] session cookie undecodable — falling through (${pathname})`); + } + } + + // Cookie present but expired/invalid — clear it on the way past. + if (hasSessionCookie) { + const res = await dashboardProxy(request); + const headers = new Headers(res.headers); + headers.append("Set-Cookie", "9r_mimo_login=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"); + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); + } + return dashboardProxy(request); } diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 589a0415..b8f67a06 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -289,6 +289,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, "codebuddy-cn", "codebuddy-intl", "qoder", + "qoder-cn", "grok-cli", ]; if (deviceCodeProviders.includes(provider)) { @@ -324,7 +325,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, _authMethod: data._authMethod, _startUrl: data._startUrl, } - : provider === "qoder" + : (provider === "qoder" || provider === "qoder-cn") ? { _qoderNonce: data._qoderNonce, _qoderMachineId: data._qoderMachineId, diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 9e97f0e6..716f59fb 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -13,7 +13,7 @@ import { ConfirmModal } from "./Modal"; import NineRemotePromoModal from "./NineRemotePromoModal"; // const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"]; -const VISIBLE_MEDIA_KINDS = ["embedding", "image", "video", "tts", "stt"]; +const VISIBLE_MEDIA_KINDS = ["embedding", "image", "video", "tts", "stt", "systemone"]; // Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" }; @@ -200,6 +200,9 @@ export default function Sidebar({ onClose }) { > perm_media Media Providers + {MEDIA_PROVIDER_KINDS.some((k) => VISIBLE_MEDIA_KINDS.includes(k.id) && k.isNew) && ( + NEW + )} expand_more @@ -220,6 +223,9 @@ export default function Sidebar({ onClose }) { > {kind.icon} {kind.label} + {kind.isNew && ( + NEW + )} ))} 9Remote - {/* - New - */} + + NEW + {/* 9English */} diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index 3dd8c5b8..49deabbb 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -26,6 +26,8 @@ import UsageTable, { fmtTime, } from "@/app/(dashboard)/dashboard/usage/components/UsageTable"; import dynamic from "next/dynamic"; +import ProviderBarChart from "@/app/(dashboard)/dashboard/usage/components/ProviderBarChart"; +import TopModelsChart from "@/app/(dashboard)/dashboard/usage/components/TopModelsChart"; // Lazy-load: keeps @xyflow/react out of the shared bundle until topology renders const ProviderTopology = dynamic( () => import("@/app/(dashboard)/dashboard/usage/components/ProviderTopology"), @@ -299,6 +301,7 @@ const PERIODS = [ { value: "7d", label: "7D" }, { value: "30d", label: "30D" }, { value: "60d", label: "60D" }, + { value: "all", label: "All" }, ]; export default function UsageStats({ @@ -730,7 +733,7 @@ export default function UsageStats({ {/* Period selector (hidden when controlled by parent) */} {!hidePeriodSelector && (
    -
    +
    {PERIODS.map((p) => ( + {sessPageUrl && ( + + )} +
    + ) : ( + + )} + {sessError &&

    {translate(sessError)}

    } +
    + ); + return ( - -
    + +
    {/* Detecting */} {phase === "detecting" && (
    -
    - +
    + progress_activity
    -

    Reading local credentials...

    -

    - Checking ~/.local/share/mimocode/auth.json -

    +

    Reading local MiMo Desktop credentials...

    )} - {/* Found — one-click import */} - {phase === "found" && detectResult && ( - <> -
    -
    - check_circle -
    -

    Xiaomi MiMo Desktop credentials found!

    -

    - UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()} -

    -
    -
    -
    - - {error && ( -
    -

    {error}

    -
    - )} - -
    - - -
    - - )} - {/* Importing */} {phase === "importing" && (
    -
    - +
    + progress_activity
    -

    Connecting...

    +

    Connecting...

    )} - {/* Not found — offer OAuth fallback */} + {/* Found local desktop credentials */} + {phase === "found" && detectResult && ( +
    +
    + desktop_windows + Desktop Plan · Local credentials +
    + + {existingConnection ? ( +
    +
    + + check_circle + +
    +

    This account is already connected (no need to import again)

    +

    + UID: {detectResult.uid || "—"} · Status: {existingConnection.testStatus === "active" ? "Active" : "Untested"} +

    +
    +
    +
    + ) : ( +
    +
    + + check_circle + +
    +

    Xiaomi MiMo Desktop credentials found!

    +

    + UID: {detectResult.uid || "—"} · Source: {detectResult.source?.split(/[\\/]/).pop()} +

    +
    +
    +
    + )} + + {error && ( +
    +

    {translate(error)}

    +
    + )} + +
    + + +
    + +
    +
    +
    +
    +
    + or +
    +
    + + {renderServerLogin()} +
    + )} + + {/* No local desktop credentials */} {phase === "not-found" && ( - <> +
    -
    - info +
    + info
    -

    Local credentials not found

    -

    {error}

    -

    - Make sure Xiaomi MiMo Desktop is installed and you are signed in, then retry. - Or sign in via browser below. +

    No local Desktop credentials found

    +

    + You can still sign in via browser — no Desktop client needed.

    - {!oauthUrl ? ( -
    - +
    +
    + )} + + {/* Cluster selection sub-modal */} + {showClusterModal && ( +
    +
    +
    +
    + public +

    Select account cluster

    +
    + - + close +
    - ) : ( + +

    Choose the region cluster of your Xiaomi account:

    +
    -
    -

    - Browser opened. Complete the Xiaomi sign-in, then click{" "} - Check Again. -

    -
    -
    - - -
    + {CLUSTERS.map((c) => ( + + ))}
    - )} - + + +
    +
    )}
    diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index a6bb4685..77c307fb 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -468,6 +468,96 @@ gemini extensions install https://github.com/manalkaff/opendesign # Fetch and follow .opencode/INSTALL.md from the repo`, }, }, + pi: { + id: "pi", + name: "Pi (pi-coding-agent)", + image: "/providers/pi.svg", + color: "#6366F1", + description: "Pi coding agent — minimal, extensible agent harness (pi.dev)", + configType: "custom", + docsUrl: "https://pi.dev", + notes: [ + { + type: "info", + text: "Pi uses ~/.pi/agent/models.json. 9Router is configured under providers.9router as an OpenAI-compatible endpoint.", + }, + ], + }, + omp: { + id: "omp", + name: "Oh My Pi", + image: "/providers/omp.png", + color: "#EC4899", + description: "Oh My Pi terminal AI agent with auto-discovery support", + configType: "custom", + docsUrl: "https://github.com/can1357/oh-my-pi", + notes: [ + { + type: "info", + text: "Oh My Pi uses ~/.omp/agent/models.yml and agent.db. 9Router is configured with proxy discovery so all models appear automatically under /model.", + }, + ], + }, + crush: { + id: "crush", + name: "Crush", + image: "/providers/crush.png", + color: "#FB923C", + description: "Charm Crush terminal AI coding agent", + configType: "custom", + docsUrl: "https://github.com/charmbracelet/crush", + notes: [ + { + type: "info", + text: "Crush uses ~/.config/crush/crush.json. 9Router registers as an openai-compat provider.", + }, + ], + }, + forge: { + id: "forge", + name: "ForgeCode", + image: "/providers/forge.png", + color: "#EAB308", + description: "Antinomy HQ ForgeCode agent harness", + configType: "custom", + docsUrl: "https://github.com/antinomyhq/forge", + notes: [ + { + type: "info", + text: "ForgeCode uses ~/.forge/config.toml. 9Router updates the [openai] section with your baseUrl, apiKey, and model.", + }, + ], + }, + smelt: { + id: "smelt", + name: "Smelt", + image: "/providers/smelt.svg", + color: "#EF4444", + description: "Smelt terminal AI coding assistant", + configType: "custom", + docsUrl: "https://github.com/leonardcser/smelt", + notes: [ + { + type: "info", + text: "Smelt uses ~/.smelt/config.json for OpenAI-compatible endpoint configuration.", + }, + ], + }, + codewhale: { + id: "codewhale", + name: "CodeWhale", + image: "/providers/codewhale.svg", + color: "#4F46E5", + description: "CodeWhale terminal coding agent (successor to DeepSeek TUI)", + configType: "custom", + docsUrl: "https://github.com/Hmbown/CodeWhale", + notes: [ + { + type: "info", + text: "CodeWhale uses ~/.codewhale/config.toml. 9Router configures the [openai] provider with your base_url, api_key, and model.", + }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 618e3b2f..1e5f30f0 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -5,7 +5,7 @@ import { RISK_NOTICE } from "@/shared/constants/providersDisplay"; const MEDIA_ENTRY_KEYS = [ "serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig", "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", - "searchViaChat", "searchConfig", "fetchConfig", "credentialFallback", + "searchViaChat", "searchConfig", "fetchConfig", "credentialFallback", "systemoneConfig", "modelsFetcher", "mediaPriority", "hiddenKinds", ]; @@ -79,6 +79,7 @@ export const MEDIA_PROVIDER_KINDS = [ { id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } }, { id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/videos/generations" } }, { id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } }, + { id: "systemone", label: "System One", icon: "psychology", endpoint: { method: "POST", path: "/v1/systemone" }, isNew: true }, ]; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; diff --git a/src/sse/handlers/systemone.js b/src/sse/handlers/systemone.js new file mode 100644 index 00000000..19f7ba08 --- /dev/null +++ b/src/sse/handlers/systemone.js @@ -0,0 +1,152 @@ +import { + getProviderCredentials, + markAccountUnavailable, + clearAccountError, + extractApiKey, + isValidApiKey, +} from "../services/auth.js"; +import { getSettings } from "@/lib/localDb"; +import { getModelInfo } from "../services/model.js"; +import { handleSystemoneCore } from "open-sse/handlers/systemoneCore.js"; +import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; +import * as log from "../utils/logger.js"; +import { checkAndRefreshToken } from "../services/tokenRefresh.js"; +import { saveRequestUsage } from "@/lib/usageDb.js"; + +/** + * Handle System One (Jev) decision requests for the Next.js server. + * Follows the same auth + account-fallback pattern as handleEmbeddings. + * + * @param {Request} request + */ +export async function handleSystemone(request) { + let body; + try { + body = await request.json(); + } catch { + log.warn("SYSTEMONE", "Invalid JSON body"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body"); + } + + const url = new URL(request.url); + const modelStr = body.model; + + log.request("POST", `${url.pathname} | ${modelStr}`); + + // Log API key (masked) + const apiKey = extractApiKey(request); + if (apiKey) { + log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); + } else { + log.debug("AUTH", "No API key provided (local mode)"); + } + + // Enforce API key if enabled in settings + const settings = await getSettings(); + if (settings.requireApiKey) { + if (!apiKey) { + log.warn("AUTH", "Missing API key (requireApiKey=true)"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + } + const valid = await isValidApiKey(apiKey); + if (!valid) { + log.warn("AUTH", "Invalid API key (requireApiKey=true)"); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + } + } + + if (!modelStr) { + log.warn("SYSTEMONE", "Missing model"); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); + } + if (body.state === undefined || body.state === null) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: state"); + } + if (!body.questions || typeof body.questions !== "object" || Array.isArray(body.questions)) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: questions"); + } + + const modelInfo = await getModelInfo(modelStr); + if (!modelInfo.provider) { + log.warn("SYSTEMONE", "Invalid model format", { model: modelStr }); + return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); + } + + const { provider, model } = modelInfo; + + if (modelStr !== `${provider}/${model}`) { + log.info("ROUTING", `${modelStr} → ${provider}/${model}`); + } else { + log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); + } + + // Credential + fallback loop (mirrors handleEmbeddings) + const excludeConnectionIds = new Set(); + let lastError = null; + let lastStatus = null; + + while (true) { + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + + // All accounts unavailable + if (!credentials || credentials.allRateLimited) { + if (credentials?.allRateLimited) { + const errorMsg = lastError || credentials.lastError || "Unavailable"; + const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + log.warn("SYSTEMONE", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); + return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); + } + if (excludeConnectionIds.size === 0) { + log.error("AUTH", `No credentials for provider: ${provider}`); + return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); + } + log.warn("SYSTEMONE", "No more accounts available", { provider }); + return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + } + + log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`); + + const refreshedCredentials = await checkAndRefreshToken(provider, credentials); + + const result = await handleSystemoneCore({ + body, + modelInfo: { provider, model }, + credentials: refreshedCredentials, + log, + onRequestSuccess: async () => { + await clearAccountError(credentials.connectionId, credentials, model); + } + }); + + if (result.success) { + if (result.usage) { + saveRequestUsage({ + provider, + model, + connectionId: credentials.connectionId, + apiKey, + endpoint: url.pathname, + tokens: { + ...result.usage, + total_tokens: result.usage.prompt_tokens + result.usage.completion_tokens, + }, + status: "success", + }).catch(() => {}); + } + return result.response; + } + + const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); + + if (shouldFallback) { + log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); + excludeConnectionIds.add(credentials.connectionId); + lastError = result.error; + lastStatus = result.status; + continue; + } + + return result.response; + } +} diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index ae3a452f..a387cb7f 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -180,12 +180,14 @@ "openai": "openai", "opencode": "oc", "opencode-go": "opencode-go", + "opencode-zen": "ocz", "openrouter": "openrouter", "perplexity": "perplexity", "perplexity-agent": "perplexity-agent", "perplexity-web": "perplexity-web", "poolside": "poolside", "qoder": "qd", + "qoder-cn": "qdcn", "sambanova": "samba", "siliconflow": "siliconflow", "tencent": "hunyuan", @@ -265,6 +267,7 @@ "nebius", "nvidia", "oc", + "ocz", "ollama", "openai", "openai-tts-models", @@ -278,6 +281,7 @@ "perplexity-web", "poolside", "qd", + "qdcn", "qianfan", "recraft", "runwayml", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 89a3db64..e28594df 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -719,6 +719,16 @@ "validateUrl": "https://ollama.com/api/tags", "format": "ollama" }, + "qoder-cn": { + "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" + }, + "format": "openai" + }, "openai": { "baseUrl": "https://api.openai.com/v1/chat/completions", "forceStream": true, @@ -762,6 +772,44 @@ } ] }, + "opencode-zen": { + "baseUrl": "https://opencode.ai/zen/v1/chat/completions", + "headers": {}, + "usage": { + "url": "https://opencode.ai/zen/v1/usage" + }, + "format": "openai", + "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" + } + } + ] + }, "opencode": { "baseUrl": "https://opencode.ai", "headers": { diff --git a/tests/unit/antigravity-weekly-dashboard.test.js b/tests/unit/antigravity-weekly-dashboard.test.js index 1c4a2784..47b618b6 100644 --- a/tests/unit/antigravity-weekly-dashboard.test.js +++ b/tests/unit/antigravity-weekly-dashboard.test.js @@ -82,17 +82,97 @@ describe("Antigravity dashboard normalization with weekly quotas", () => { expect(weeklyRows[1].name).toMatch(/Weekly/); }); - it("order: gemini family, claude family, weekly, then other", () => { - const quotas = parseQuotaData("antigravity", data); + it("order: session, weekly, then other models", () => { + const dataWithBoth = { + quotas: { + gemini_session: { + displayName: "Gemini (5h)", + used: 100, + total: 1000, + resetAt: "2026-09-08T05:00:00Z", + remainingPercentage: 90, + }, + gemini_weekly: { + displayName: "Gemini (Weekly)", + used: 250, + total: 1000, + resetAt: "2026-09-15T00:00:00Z", + remainingPercentage: 75, + }, + claude_gpt_session: { + displayName: "Claude & GPT (5h)", + used: 50, + total: 1000, + resetAt: "2026-09-08T05:00:00Z", + remainingPercentage: 95, + }, + claude_gpt_weekly: { + displayName: "Claude & GPT (Weekly)", + used: 500, + total: 1000, + resetAt: "2026-09-14T00:00:00Z", + remainingPercentage: 50, + }, + }, + }; + const quotas = parseQuotaData("antigravity", dataWithBoth); const keys = quotas.map((q) => q.modelKey); - const geminiIdx = keys.indexOf("gemini"); - const claudeIdx = keys.indexOf("claude"); + const geminiSessionIdx = keys.indexOf("gemini_session"); const geminiWeeklyIdx = keys.indexOf("gemini_weekly"); + const claudeSessionIdx = keys.indexOf("claude_gpt_session"); const claudeWeeklyIdx = keys.indexOf("claude_gpt_weekly"); - expect(geminiIdx).toBeLessThan(geminiWeeklyIdx); - expect(claudeIdx).toBeLessThan(claudeWeeklyIdx); + expect(geminiSessionIdx).toBeLessThan(geminiWeeklyIdx); + expect(claudeSessionIdx).toBeLessThan(claudeWeeklyIdx); + }); + + it("excludes redundant duplicates when individual models mirror weekly reset and summary is present", () => { + // Exact scenario from Christian's account: + const liveLikeData = { + quotas: { + "gemini-3.8-flash-high": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-23T06:00:17Z", displayName: "Gemini 3.8 Flash (High)" }, + "claude-sonnet-4-6": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-20T19:00:21Z", displayName: "Claude 3.7 Sonnet" }, + "gpt-oss-120b-medium": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-20T19:00:21Z", displayName: "GPT-OSS 120B (Medium)" }, + "gemini-3.1-flash-image": { used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-23T06:00:17Z", displayName: "Gemini 3.1 Flash Image" }, + gemini_weekly: { + displayName: "Gemini (Weekly)", + used: 1000, + total: 1000, + resetAt: "2026-09-23T06:00:17Z", + remainingPercentage: 0, + }, + claude_gpt_weekly: { + displayName: "Claude & GPT (Weekly)", + used: 807, + total: 1000, + resetAt: "2026-09-24T18:09:46Z", + remainingPercentage: 19.3, + }, + claude_gpt_session: { + displayName: "Claude & GPT (5h)", + used: 1000, + total: 1000, + resetAt: "2026-09-20T19:00:21Z", + remainingPercentage: 0, + }, + }, + }; + + const quotas = parseQuotaData("antigravity", liveLikeData); + const names = quotas.map((q) => q.name); + + // Should contain unique usages: + expect(names).toContain("Claude & GPT (5h)"); + expect(names).toContain("Claude & GPT (Weekly)"); + expect(names).toContain("Gemini (Weekly)"); + expect(names).toContain("Gemini 3.1 Flash Image"); + + // Should NOT contain redundant duplicate entries: + expect(names).not.toContain("Gemini (Flash / Pro)"); // duplicate of Gemini (Weekly) + expect(names).not.toContain("Claude (Sonnet / Opus)"); // duplicate of Claude & GPT (5h) + expect(names).not.toContain("GPT-OSS 120B (Medium)"); // covered by Claude & GPT family + expect(quotas).toHaveLength(4); }); it("works with no weekly keys present (backward compat)", () => { diff --git a/tests/unit/antigravity-weekly-quota.test.js b/tests/unit/antigravity-weekly-quota.test.js index 55cb3a81..a565a2c1 100644 --- a/tests/unit/antigravity-weekly-quota.test.js +++ b/tests/unit/antigravity-weekly-quota.test.js @@ -53,7 +53,7 @@ const NESTED_RESPONSE = { // — parseWeeklyQuotaSummary ——————————————————————————————— describe("parseWeeklyQuotaSummary", () => { - it("extracts Gemini weekly quota from top-level groups", () => { + it("extracts Gemini weekly and session quotas from top-level groups", () => { const result = parseWeeklyQuotaSummary(FULL_RESPONSE); expect(result.gemini_weekly).toMatchObject({ used: 250, @@ -63,6 +63,14 @@ describe("parseWeeklyQuotaSummary", () => { unlimited: false, }); expect(result.gemini_weekly.resetAt).toBe("2026-09-15T00:00:00.000Z"); + expect(result.gemini_session).toMatchObject({ + used: 100, + total: 1000, + remainingPercentage: 90, + displayName: "Gemini (5h)", + unlimited: false, + }); + expect(result.gemini_session.resetAt).toBe("2026-09-09T00:00:00.000Z"); }); it("extracts Claude & GPT weekly quota", () => { @@ -85,14 +93,14 @@ describe("parseWeeklyQuotaSummary", () => { expect(result.claude_gpt_weekly.remainingPercentage).toBe(50); }); - it("skips non-weekly buckets", () => { + it("skips unrecognized non-weekly non-session buckets", () => { const data = { groups: [{ displayName: "Gemini Models", buckets: [ { - bucketId: "gemini-daily-bucket", - displayName: "Daily Limit", + bucketId: "gemini-monthly-bucket", + displayName: "Monthly Limit", remainingFraction: 0.9, resetTime: "2026-09-09T00:00:00Z", }, @@ -404,7 +412,7 @@ describe("weekly quota isolation from existing quota", () => { }); }); - it("reconciles weekly quota to 0% when all paid-tier family models are exhausted", async () => { + it("reconciles 5h session quota to 0% when all paid-tier family models are exhausted without clobbering weekly quota", async () => { proxyAwareFetch.mockImplementation(async (url) => { if (url.includes(":loadCodeAssist")) { return { @@ -421,7 +429,7 @@ describe("weekly quota isolation from existing quota", () => { models: { "gemini-3.8-flash-high": { displayName: "Gemini 3.8 Flash (High)", - // Exhausted model: no remainingFraction, future resetTime + // Exhausted model: no remainingFraction, future resetTime (5h window reset) quotaInfo: { resetTime: "2026-09-13T12:00:00Z" }, }, }, @@ -435,30 +443,49 @@ describe("weekly quota isolation from existing quota", () => { json: async () => ({ groups: [{ displayName: "Gemini Models", - buckets: [{ - bucketId: "gemini-weekly", - displayName: "Weekly Limit Remaining", - remainingFraction: 1, - resetTime: "2026-09-15T00:00:00Z", - }], + buckets: [ + { + bucketId: "gemini-weekly", + displayName: "Weekly Limit Remaining", + window: "weekly", + remainingFraction: 0.75, + resetTime: "2026-09-15T00:00:00Z", + }, + { + bucketId: "gemini-5h", + displayName: "Five Hour Limit Remaining", + window: "5h", + remainingFraction: 1, + resetTime: "2026-09-13T11:00:00Z", + }, + ], }], }), }; } - return { ok: false, status: 404 }; + return { ok: true, status: 200, json: async () => ({}) }; }); const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js"); - const result = await getAntigravityUsage("token", {}); + const result = await getAntigravityUsage("token-exhausted", null); // Per-model quota should show exhausted expect(result.quotas["gemini-3.8-flash-high"].remainingPercentage).toBe(0); - // Weekly quota should be reconciled to 0% with the family reset time - expect(result.quotas.gemini_weekly).toMatchObject({ + + // 5h session quota should be reconciled to 0% with the family reset time + expect(result.quotas.gemini_session).toMatchObject({ used: 1000, total: 1000, remainingPercentage: 0, resetAt: "2026-09-13T12:00:00.000Z", }); + + // Weekly quota should remain intact and NOT be clobbered to 0% or steal the 5h resetAt + expect(result.quotas.gemini_weekly).toMatchObject({ + used: 250, + total: 1000, + remainingPercentage: 75, + resetAt: "2026-09-15T00:00:00.000Z", + }); }); }); diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index 84a8643c..555c32ec 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -114,3 +114,139 @@ describe("getCapabilitiesForModel", () => { }); }); }); + +describe("getCapabilitiesForModel — MiMo (-tag reasoning, always-on)", () => { + it("mimo-v2.5 has vision + reasoning + deepseek format, cannot disable", () => { + const caps = getCapabilitiesForModel(null, "mimo-v2.5"); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingFormat).toBe("deepseek"); + expect(caps.thinkingCanDisable).toBe(false); + }); + + it("mimo-v2.5-pro has vision (matches *mimo*v2.5* pattern)", () => { + const caps = getCapabilitiesForModel(null, "mimo-v2.5-pro"); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingFormat).toBe("deepseek"); + expect(caps.thinkingCanDisable).toBe(false); + }); + + it("xiaomi/mimo-v2.5-pro (vendor-prefixed) has vision", () => { + const caps = getCapabilitiesForModel(null, "xiaomi/mimo-v2.5-pro"); + expect(caps.vision).toBe(true); + expect(caps.thinkingFormat).toBe("deepseek"); + }); + + it("mimo-omni-x has audioInput via the omni pattern", () => { + const caps = getCapabilitiesForModel(null, "mimo-omni-x"); + expect(caps.vision).toBe(true); + expect(caps.audioInput).toBe(true); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingCanDisable).toBe(false); + }); + + it("generic mimo has vision + reasoning (fallback pattern)", () => { + const caps = getCapabilitiesForModel(null, "mimo"); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingCanDisable).toBe(false); + }); +}); + +describe("getCapabilitiesForModel — Qwen max/plus vision", () => { + it("qwen3.7-max has vision (*qwen*max* fires before *qwen3.7*)", () => { + const caps = getCapabilitiesForModel(null, "qwen3.7-max"); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + }); + + it("Qwen3.6-Max-Preview has vision (case-insensitive pattern match)", () => { + const caps = getCapabilitiesForModel(null, "Qwen3.6-Max-Preview"); + expect(caps.vision).toBe(true); + }); + + it("qwen3.7-plus has vision", () => { + const caps = getCapabilitiesForModel(null, "qwen3.7-plus"); + expect(caps.vision).toBe(true); + }); + + it("qwen3.7 has vision from the qwen3.7 pattern", () => { + const caps = getCapabilitiesForModel(null, "qwen3.7"); + expect(caps.vision).toBe(true); + }); + + it("qwq has no vision (thinking-only model)", () => { + const caps = getCapabilitiesForModel(null, "qwq-32b"); + expect(caps.vision).toBe(false); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingCanDisable).toBe(false); + }); +}); + +describe("getCapabilitiesForModel — MiniMax M2.x vision", () => { + it("minimax-m2.7 has vision", () => { + const caps = getCapabilitiesForModel(null, "minimax-m2.7"); + expect(caps.vision).toBe(true); + expect(caps.thinkingCanDisable).toBe(false); + }); + + it("minimax-m2.5 has vision", () => { + const caps = getCapabilitiesForModel(null, "minimax-m2.5"); + expect(caps.vision).toBe(true); + expect(caps.thinkingCanDisable).toBe(false); + }); + + it("MiniMax-M2.7 has vision (vendor prefix MiniMaxAI/ stripped by route)", () => { + const caps = getCapabilitiesForModel(null, "MiniMaxAI/MiniMax-M2.7"); + expect(caps.vision).toBe(true); + }); + + it("minimax-m3 has vision (separate pattern)", () => { + const caps = getCapabilitiesForModel(null, "minimax-m3"); + expect(caps.vision).toBe(true); + }); +}); + +describe("getCapabilitiesForModel — DeepSeek V4 text-only", () => { + it("deepseek-v4-pro has no vision", () => { + const caps = getCapabilitiesForModel(null, "deepseek-v4-pro"); + expect(caps.vision).toBe(false); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingFormat).toBe("deepseek"); + }); + + it("deepseek-v4-flash has no vision", () => { + const caps = getCapabilitiesForModel(null, "deepseek-v4-flash"); + expect(caps.vision).toBe(false); + expect(caps.reasoning).toBe(true); + }); + + it("deepseek/deepseek-v4-pro (vendor-prefixed) has no vision", () => { + const caps = getCapabilitiesForModel(null, "deepseek/deepseek-v4-pro"); + expect(caps.vision).toBe(false); + }); +}); + +describe("getCapabilitiesForModel — codebuddy-cn provider overrides", () => { + it("deepseek-v4-pro via codebuddy-cn uses openai thinking format", () => { + const caps = getCapabilitiesForModel("codebuddy-cn", "deepseek-v4-pro"); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingFormat).toBe("openai"); + expect(caps.thinkingCanDisable).toBe(true); + }); + + it("minimax-m3 via codebuddy-cn has vision (provider override)", () => { + const caps = getCapabilitiesForModel("codebuddy-cn", "minimax-m3"); + expect(caps.vision).toBe(true); + expect(caps.thinkingFormat).toBe("openai"); + expect(caps.thinkingCanDisable).toBe(false); + }); + + it("unknown provider falls through to pattern matching", () => { + const caps = getCapabilitiesForModel("unknown-provider", "mimo-v2.5"); + expect(caps.vision).toBe(true); + expect(caps.thinkingFormat).toBe("deepseek"); + }); +}); diff --git a/tests/unit/claude-refusal-stream.test.js b/tests/unit/claude-refusal-stream.test.js new file mode 100644 index 00000000..5c281f84 --- /dev/null +++ b/tests/unit/claude-refusal-stream.test.js @@ -0,0 +1,76 @@ +// A refusal from the Anthropic API (stop_reason "refusal", zero output tokens, no +// content blocks) must reach an OpenAI-format client as finish_reason +// "content_filter" carrying Anthropic's explanation — not as a clean, empty "stop". +// Captured live on 2026-09-20 against claude-opus-5 via a Claude Code OAuth +// connection: 9Router logged "Model succeeded · OUT 0" and the client saw nothing. +import { describe, it, expect } from "vitest"; +import { claudeToOpenAIResponse } from "../../open-sse/translator/response/claude-to-openai.js"; + +const EXPLANATION = + "This request was blocked as it seems to violate Anthropic's Terms of Service restrictions on reverse engineering or duplicating model outputs."; + +function runStream(events) { + const state = {}; + const out = []; + for (const ev of events) { + const r = claudeToOpenAIResponse(ev, state); + if (Array.isArray(r)) out.push(...r); + else if (r) out.push(r); + } + return { state, out }; +} + +const refusalStream = [ + { + type: "message_start", + message: { + id: "msg_refusal", model: "claude-opus-5", role: "assistant", content: [], + usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 } + } + }, + { + type: "message_delta", + delta: { + stop_reason: "refusal", + stop_sequence: null, + stop_details: { type: "refusal", category: "reasoning_extraction", explanation: EXPLANATION } + }, + usage: { input_tokens: 637, cache_creation_input_tokens: 206779, cache_read_input_tokens: 0, output_tokens: 0 } + }, + { type: "message_stop" } +]; + +describe("claude-to-openai: refusal stop_reason", () => { + it("finishes with content_filter, not stop", () => { + const { out } = runStream(refusalStream); + const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean); + expect(finishes).toEqual(["content_filter"]); + }); + + it("surfaces Anthropic's explanation as message content", () => { + const { out } = runStream(refusalStream); + const text = out.map(c => c.choices?.[0]?.delta?.content || "").join(""); + expect(text).toBe(EXPLANATION); + }); + + it("keeps usage on the final chunk (prompt tokens were billed)", () => { + const { out } = runStream(refusalStream); + const final = out.find(c => c.choices?.[0]?.finish_reason === "content_filter"); + expect(final.usage.prompt_tokens).toBe(637 + 206779); + expect(final.usage.completion_tokens).toBe(0); + }); + + it("leaves a normal end_turn untouched", () => { + const { out } = runStream([ + { type: "message_start", message: { id: "m", model: "claude-opus-5", role: "assistant", content: [], usage: { input_tokens: 5, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null, stop_details: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" } + ]); + const finishes = out.map(c => c.choices?.[0]?.finish_reason).filter(Boolean); + expect(finishes).toEqual(["stop"]); + expect(out.map(c => c.choices?.[0]?.delta?.content || "").join("")).toBe("ok"); + }); +}); diff --git a/tests/unit/combo-capabilities.test.js b/tests/unit/combo-capabilities.test.js new file mode 100644 index 00000000..10a3d367 --- /dev/null +++ b/tests/unit/combo-capabilities.test.js @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { aggregateComboCapabilities } from "../../open-sse/providers/capabilities.js"; + +describe("aggregateComboCapabilities — null / empty", () => { + it("returns null for null", () => { + expect(aggregateComboCapabilities(null)).toBeNull(); + }); + + it("returns null for empty array", () => { + expect(aggregateComboCapabilities([])).toBeNull(); + }); +}); + +describe("aggregateComboCapabilities — single model passthrough", () => { + it("single model returns its own capabilities", () => { + const caps = aggregateComboCapabilities(["opencode-go/mimo-v2.5"]); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + expect(caps.thinkingFormat).toBe("deepseek"); + expect(caps.thinkingCanDisable).toBe(false); + expect(caps.contextWindow).toBe(1048576); + expect(caps.maxOutput).toBe(131072); + }); +}); + +describe("aggregateComboCapabilities — union fields (vision, audioInput, search)", () => { + it("vision is true if any backend has it", () => { + // deepseek-v4-pro: no vision; mimo-v2.5: vision + const caps = aggregateComboCapabilities([ + "opencode-go/deepseek-v4-pro", + "opencode-go/mimo-v2.5", + ]); + expect(caps.vision).toBe(true); + }); + + it("vision is false if no backend has it", () => { + const caps = aggregateComboCapabilities([ + "opencode-go/deepseek-v4-pro", + "opencode-go/deepseek-v4-flash", + ]); + expect(caps.vision).toBe(false); + }); + + it("audioInput is true if any backend has it", () => { + // mimo-omni has audioInput; mimo-v2.5 does not + const caps = aggregateComboCapabilities([ + "opencode-go/mimo-v2.5", + "opencode-go/mimo-omni-test", + ]); + expect(caps.audioInput).toBe(true); + }); + + it("search is true if any backend has it", () => { + // gpt-5: search; mimo-v2.5: no search + const caps = aggregateComboCapabilities([ + "openai/gpt-5", + "opencode-go/mimo-v2.5", + ]); + expect(caps.search).toBe(true); + }); +}); + +describe("aggregateComboCapabilities — intersection: tools", () => { + it("tools is false if any backend lacks it", () => { + // gpt-image-1: tools:false; gpt-5: tools:true + const caps = aggregateComboCapabilities([ + "openai/gpt-5", + "openai/gpt-image-1", + ]); + expect(caps.tools).toBe(false); + }); + + it("tools is true when all backends support it", () => { + const caps = aggregateComboCapabilities([ + "opencode-go/mimo-v2.5", + "opencode-go/kimi-k2.5", + ]); + expect(caps.tools).toBe(true); + }); +}); + +describe("aggregateComboCapabilities — primary model drives reasoning fields", () => { + it("thinkingFormat comes from the first model", () => { + // primary: mimo-v2.5 (deepseek); secondary: kimi-k2.5 (kimi) + const caps = aggregateComboCapabilities([ + "opencode-go/mimo-v2.5", + "opencode-go/kimi-k2.5", + ]); + expect(caps.thinkingFormat).toBe("deepseek"); + expect(caps.reasoning).toBe(true); + }); + + it("flipping order changes thinkingFormat to the new primary", () => { + const caps = aggregateComboCapabilities([ + "opencode-go/kimi-k2.5", + "opencode-go/mimo-v2.5", + ]); + expect(caps.thinkingFormat).toBe("kimi"); + }); +}); + +describe("aggregateComboCapabilities — context/output limits", () => { + it("contextWindow is the minimum across all models", () => { + // mimo-v2.5: 1048576; kimi-k2.5 (*kimi*k2* pattern): 262144 + const caps = aggregateComboCapabilities([ + "opencode-go/mimo-v2.5", + "opencode-go/kimi-k2.5", + ]); + expect(caps.contextWindow).toBe(262144); + }); + + it("maxOutput is the maximum across all models", () => { + // mimo-v2.5: 131072; kimi-k2.5 (*kimi*k2* pattern): 262144 + const caps = aggregateComboCapabilities([ + "opencode-go/mimo-v2.5", + "opencode-go/kimi-k2.5", + ]); + expect(caps.maxOutput).toBe(262144); + }); +}); + +describe("aggregateComboCapabilities — nested combo resolution via comboLookup", () => { + it("resolves nested combo and unions vision from its members", () => { + const lookup = { "inner-combo": ["opencode-go/deepseek-v4-pro", "opencode-go/mimo-v2.5"] }; + const caps = aggregateComboCapabilities(["inner-combo"], lookup); + expect(caps.reasoning).toBe(true); + expect(caps.vision).toBe(true); // mimo brings vision through the lookup + }); + + it("outer combo gets vision via nested combo containing mimo", () => { + const lookup = { "deepseek-v4-pro-fusion": ["opencode-go/deepseek-v4-pro", "opencode-go/mimo-v2.5"] }; + const caps = aggregateComboCapabilities(["deepseek-v4-pro-fusion", "openai/gpt-5"], lookup); + expect(caps.vision).toBe(true); + expect(caps.reasoning).toBe(true); + }); + + it("contextWindow is min across all resolved leaves", () => { + // deepseek-v4-pro (*deepseek-v4*): 1000000; mimo-v2.5: 1048576 → min = 1000000 + const lookup = { "inner": ["opencode-go/deepseek-v4-pro"] }; + const caps = aggregateComboCapabilities(["inner", "opencode-go/mimo-v2.5"], lookup); + expect(caps.contextWindow).toBe(1000000); + }); + + it("handles cycles without throwing", () => { + const lookup = { "a": ["b"], "b": ["a"] }; + expect(() => aggregateComboCapabilities(["a"], lookup)).not.toThrow(); + }); + + it("without comboLookup bare combo name falls through to pattern match", () => { + // *deepseek-v4* pattern: reasoning true, vision false + const caps = aggregateComboCapabilities(["deepseek-v4-pro-fusion"]); + expect(caps.reasoning).toBe(true); + expect(caps.vision).toBe(false); + }); +}); diff --git a/tests/unit/combo-presets.test.js b/tests/unit/combo-presets.test.js new file mode 100644 index 00000000..e5a06554 --- /dev/null +++ b/tests/unit/combo-presets.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { + buildPresetItems, + buildCursorPresetItems, + buildClaudePresetItems, + isValidComboPresetName, +} from "../../src/lib/comboPresets.js"; + +describe("combo presets", () => { + it("rejects combo names with slashes or invalid chars", () => { + expect(isValidComboPresetName("composer-2.5")).toBe(true); + expect(isValidComboPresetName("claude-opus-5")).toBe(true); + expect(isValidComboPresetName("cu/composer-2.5")).toBe(false); + expect(isValidComboPresetName("bad name")).toBe(false); + expect(isValidComboPresetName("")).toBe(false); + }); + + it("Cursor live ids become unprefixed names seeded with cu/…", () => { + const items = buildCursorPresetItems({ + liveModels: [ + { id: "composer-2.5", name: "Composer 2.5" }, + { id: "cursor-grok-4.6-high-fast", name: "Grok" }, + { id: "bad/with-slash", name: "Invalid" }, + ], + }); + + expect(items).toEqual([ + { name: "composer-2.5", models: ["cu/composer-2.5"] }, + { name: "cursor-grok-4.6-high-fast", models: ["cu/cursor-grok-4.6-high-fast"] }, + ]); + }); + + it("Cursor falls back to static cu registry when live catalog is empty", () => { + const items = buildCursorPresetItems({ liveModels: [] }); + expect(items.length).toBeGreaterThan(0); + expect(items.every((i) => i.models[0].startsWith("cu/"))).toBe(true); + expect(items.some((i) => i.name === "default")).toBe(true); + // No slash in combo name + expect(items.every((i) => !i.name.includes("/"))).toBe(true); + }); + + it("Claude aliases map opus → cc/claude-opus-5 and registry models seed cc/…", () => { + const items = buildClaudePresetItems(); + const byName = Object.fromEntries(items.map((i) => [i.name, i])); + + expect(byName["claude-opus-5"]).toEqual({ + name: "claude-opus-5", + models: ["cc/claude-opus-5"], + }); + expect(byName.opus).toEqual({ + name: "opus", + models: ["cc/claude-opus-5"], + }); + expect(byName.sonnet).toEqual({ + name: "sonnet", + models: ["cc/claude-sonnet-5"], + }); + expect(byName.haiku).toEqual({ + name: "haiku", + models: ["cc/claude-haiku-4-5-20251001"], + }); + expect(byName.fable).toEqual({ + name: "fable", + models: ["cc/claude-fable-5"], + }); + expect(byName.default).toEqual({ + name: "default", + models: ["cc/claude-sonnet-5"], + }); + expect(byName.opusplan).toEqual({ + name: "opusplan", + models: ["cc/claude-opus-5"], + }); + }); + + it("marks existing names with exists: true", () => { + const items = buildPresetItems("cursor", { + liveModels: [ + { id: "composer-2.5" }, + { id: "gpt-5.3-codex" }, + ], + existingNames: ["composer-2.5"], + }); + + expect(items).toEqual([ + { name: "composer-2.5", models: ["cu/composer-2.5"], exists: true }, + { name: "gpt-5.3-codex", models: ["cu/gpt-5.3-codex"], exists: false }, + ]); + }); + + it("returns empty for unknown source", () => { + expect(buildPresetItems("unknown")).toEqual([]); + }); + + it("drops invalid names from Claude/Cursor catalogs", () => { + const cursor = buildPresetItems("cursor", { + liveModels: [{ id: "ok-model" }, { id: "no/slash" }, { id: "has space" }], + existingNames: [], + }); + expect(cursor.map((i) => i.name)).toEqual(["ok-model"]); + }); +}); diff --git a/tests/unit/cursor-agent-exec-request.test.js b/tests/unit/cursor-agent-exec-request.test.js index 347e159f..18389c18 100644 --- a/tests/unit/cursor-agent-exec-request.test.js +++ b/tests/unit/cursor-agent-exec-request.test.js @@ -18,6 +18,18 @@ function textFrame(text) { return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update))); } +// InteractionUpdate.thinking_delta (field 4) + turn_ended (field 14). +function thinkingFrame(text) { + const thinkingPart = Buffer.from(encodeField(1, LEN, text)); + const update = Buffer.from(encodeField(4, LEN, thinkingPart)); + return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update))); +} + +function turnEndedFrame() { + const update = Buffer.from(encodeField(14, LEN, new Uint8Array())); + return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update))); +} + function stubAgentSession(executor, frames) { const written = []; const queue = [...frames]; @@ -48,12 +60,12 @@ function parseSSE(text) { .map((data) => JSON.parse(data)); } -async function runAgent({ frames, stream }) { +async function runAgent({ frames, stream, model = "gpt-5.2", tools }) { const executor = new CursorExecutor(); const written = stubAgentSession(executor, frames); const result = await executor.executeAgent({ - model: "gpt-5.2", - body: { messages: [{ role: "user", content: "hi" }] }, + model, + body: { messages: [{ role: "user", content: "hi" }], ...(tools ? { tools } : {}) }, stream, credentials, }); @@ -73,32 +85,45 @@ describe("CursorExecutor AgentService exec_request handling", () => { expect(content).toBe("hello"); }); + it("does not echo client tools on the request_context ack", async () => { + const { written, result } = await runAgent({ + tools: [{ function: { name: "read_file", parameters: { type: "object" } } }], + frames: [execRequestFrame(10), textFrame("hello")], + stream: true, + }); + + expect(written.length).toBe(2); + expect(written[1].toString("utf8")).not.toContain("read_file"); + const content = parseSSE(await result.response.text()) + .map((e) => e.choices?.[0]?.delta?.content || "") + .join(""); + expect(content).toBe("hello"); + }); + it("does not render an unsupported exec request as assistant content", async () => { - const { result } = await runAgent({ - frames: [textFrame("partial answer"), execRequestFrame(2)], + const { result, written } = await runAgent({ + frames: [textFrame("partial answer"), execRequestFrame(2), textFrame(" more")], stream: true, }); const body = await result.response.text(); - expect(body).not.toContain("unsupported IDE tool\\n"); + expect(body).not.toContain("unsupported IDE tool"); const events = parseSSE(body); const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); - expect(content).toBe("partial answer"); - - const errorEvent = events.find((e) => e.error); - expect(errorEvent?.error?.message).toContain("unsupported IDE tool"); - expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false); + expect(content).toBe("partial answer more"); + expect(events.some((e) => e.error)).toBe(false); + expect(written.length).toBe(2); // run frame + IDE rejection }); - it("drops frames batched behind an unsupported exec request in the same read", async () => { + it("still emits later text after rejecting an IDE exec in the same read", async () => { const { result } = await runAgent({ frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])], stream: true, }); const body = await result.response.text(); - expect(body).toContain("unsupported IDE tool"); - expect(body).not.toContain("late"); + expect(body).not.toContain("unsupported IDE tool"); + expect(body).toContain("late"); }); it("returns a non-200 error body for an unsupported exec request when not streaming", async () => { @@ -111,4 +136,32 @@ describe("CursorExecutor AgentService exec_request handling", () => { const payload = await result.response.json(); expect(payload.error.message).toContain("unsupported IDE tool"); }); + + it("streams Composer visible content from thinking_delta after ", async () => { + const { result } = await runAgent({ + model: "composer-2.5", + frames: [ + thinkingFrame("private reasoning that must not leakOK"), + turnEndedFrame(), + ], + stream: true, + }); + + const events = parseSSE(await result.response.text()); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("OK"); + expect(JSON.stringify(events)).not.toContain("private reasoning"); + }); + + it("flushes Grok thinking as visible content when the turn has no text_delta", async () => { + const { result } = await runAgent({ + model: "grok-4.5", + frames: [thinkingFrame("hello from grok"), turnEndedFrame()], + stream: true, + }); + + const events = parseSSE(await result.response.text()); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("hello from grok"); + }); }); diff --git a/tests/unit/cursor-agent-proto.test.js b/tests/unit/cursor-agent-proto.test.js index 2d571aba..b112cc58 100644 --- a/tests/unit/cursor-agent-proto.test.js +++ b/tests/unit/cursor-agent-proto.test.js @@ -246,6 +246,15 @@ describe("Cursor AgentService executor helpers (cursor.js)", () => { const run = decodeMessage(clientMsg.get(1)[0].value); expect(run.has(2)).toBe(true); // action expect(run.has(9)).toBe(true); // requested_model + // custom_system_prompt (field 8) makes AgentService return an empty turn. + expect(run.has(8)).toBe(false); + expect(run.has(3)).toBe(true); // ModelDetails — required for thinking variants + const action = decodeMessage(run.get(2)[0].value); + const userAction = decodeMessage(action.get(1)[0].value); + const userMessage = decodeMessage(userAction.get(1)[0].value); + const userText = Buffer.from(userMessage.get(1)[0].value).toString("utf8"); + expect(userText).toContain("be brief"); + expect(userText).toContain("hi"); }); it("encodes mcp_tools (field 4) when tools are provided", () => { diff --git a/tests/unit/finish-reason-concern.test.js b/tests/unit/finish-reason-concern.test.js index d4fc4713..bb233bf3 100644 --- a/tests/unit/finish-reason-concern.test.js +++ b/tests/unit/finish-reason-concern.test.js @@ -40,6 +40,9 @@ describe("toOpenAIFinish - claude", () => { ["end_turn", "stop"], ["max_tokens", "length"], ["tool_use", "tool_calls"], + ["stop_sequence", "stop"], + ["refusal", "content_filter"], + ["unknown_xyz", "stop"], ])("%s -> %s", (input, expected) => { expect(toOpenAIFinish(input, "claude")).toBe(expected); }); @@ -58,6 +61,9 @@ describe("fromOpenAIFinish round-trip - claude", () => { it("tool_calls -> tool_use", () => { expect(fromOpenAIFinish("tool_calls", "claude")).toBe("tool_use"); }); + it("content_filter -> refusal", () => { + expect(fromOpenAIFinish("content_filter", "claude")).toBe("refusal"); + }); it("length -> max_tokens", () => { expect(fromOpenAIFinish("length", "claude")).toBe("max_tokens"); }); diff --git a/tests/unit/huggingface-image-end-to-end.test.js b/tests/unit/huggingface-image-end-to-end.test.js new file mode 100644 index 00000000..8ce37268 --- /dev/null +++ b/tests/unit/huggingface-image-end-to-end.test.js @@ -0,0 +1,144 @@ +/** + * HuggingFace image generation — end-to-end through the real core handler. + * + * The registry/adapter tests pin the URL and payload in isolation. These tests + * drive `handleImageGenerationCore` — the same function the `/v1/images/generations` + * route calls — so the whole seam is exercised: adapter selection, buildUrl / + * buildBody / buildHeaders, the fetch call, and the binary response parse. + * + * The mocked `fetch` asserts on the exact request the router would receive, which + * is the strongest check available without burning live Inference Providers credits + * (the router bills before validating the payload, so a live probe can only prove + * the path exists, never that the body is right). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { handleImageGenerationCore } from "../../open-sse/handlers/imageGenerationCore.js"; + +const originalFetch = global.fetch; +const CREDS = { apiKey: "hf_test_token" }; + +// A 1x1 transparent PNG — enough to prove the bytes survive the round trip. +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + "base64" +); + +function mockBinaryResponse() { + return { + ok: true, + status: 200, + arrayBuffer: async () => PNG_1X1.buffer.slice(PNG_1X1.byteOffset, PNG_1X1.byteOffset + PNG_1X1.byteLength), + }; +} + +async function generate(body, model) { + return handleImageGenerationCore({ + body, + modelInfo: { provider: "huggingface", model }, + credentials: CREDS, + log: null, + }); +} + +describe("HuggingFace image generation — end to end", () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue(mockBinaryResponse()); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("posts a text-to-image request to the fal-ai router path", async () => { + const result = await generate({ prompt: "a lighthouse at dusk" }, "black-forest-labs/FLUX.1-schnell"); + + expect(result.success).toBe(true); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://router.huggingface.co/fal-ai/fal-ai/flux/schnell"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body)).toEqual({ inputs: "a lighthouse at dusk" }); + }); + + it("authenticates with the connection's API key", async () => { + await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + const [, init] = global.fetch.mock.calls[0]; + expect(init.headers.Authorization).toBe("Bearer hf_test_token"); + }); + + it("never touches the dead api-inference host", async () => { + await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + expect(global.fetch.mock.calls[0][0]).not.toContain("api-inference.huggingface.co"); + }); + + it("posts an image-to-image request with the source image in inputs", async () => { + const result = await generate( + { prompt: "make it snow", image: "data:image/png;base64,AAAB" }, + "Qwen/Qwen-Image-Edit" + ); + + expect(result.success).toBe(true); + + const [url, init] = global.fetch.mock.calls[0]; + expect(url).toBe("https://router.huggingface.co/fal-ai/fal-ai/qwen-image-edit"); + // The router takes raw base64 in inputs and the prompt under parameters — + // the data-URL prefix must be stripped, not forwarded. + expect(JSON.parse(init.body)).toEqual({ + inputs: "AAAB", + parameters: { prompt: "make it snow" }, + }); + }); + + it("rejects an image-to-image model that was given no source image", async () => { + const result = await generate({ prompt: "make it snow" }, "Qwen/Qwen-Image-Edit"); + + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(result.error).toMatch(/requires a source image/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects a model with no router mapping before calling upstream", async () => { + const result = await generate({ prompt: "x" }, "some-org/unmapped-model"); + + expect(result.success).toBe(false); + expect(result.status).toBe(400); + expect(result.error).toMatch(/no HuggingFace router mapping/i); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns the generated image as base64 to the client", async () => { + const result = await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + const payload = await result.response.json(); + expect(payload.data[0].b64_json).toBe(PNG_1X1.toString("base64")); + }); + + it("routes a self-hosted connection to its own endpoint", async () => { + await handleImageGenerationCore({ + body: { prompt: "x" }, + modelInfo: { provider: "huggingface", model: "my-org/my-tgi-model" }, + credentials: { apiKey: "k", providerSpecificData: { baseUrl: "https://tgi.internal" } }, + log: null, + }); + + expect(global.fetch.mock.calls[0][0]).toBe("https://tgi.internal/my-org/my-tgi-model"); + }); + + it("surfaces an upstream error instead of a broken image", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 402, + text: async () => JSON.stringify({ error: "You have depleted your monthly included credits." }), + json: async () => ({ error: "You have depleted your monthly included credits." }), + }); + + const result = await generate({ prompt: "x" }, "black-forest-labs/FLUX.1-schnell"); + + expect(result.success).toBe(false); + expect(result.status).toBe(402); + }); +}); diff --git a/tests/unit/huggingface-router-migration.test.js b/tests/unit/huggingface-router-migration.test.js new file mode 100644 index 00000000..cfa46e3b --- /dev/null +++ b/tests/unit/huggingface-router-migration.test.js @@ -0,0 +1,349 @@ +/** + * HuggingFace registry migration to router.huggingface.co + * + * The legacy base URL `https://api-inference.huggingface.co` no longer resolves + * (DNS ENOTFOUND), so every HuggingFace image/STT request failed at the fetch + * layer. The replacement is `https://router.huggingface.co`, which routes by + * `/` — the provider-resolved id is NOT the + * Hub model id and must be resolved from the Hub API's inferenceProviderMapping. + * + * Covers: + * - imageConfig base URL is the live router host, not the dead legacy host + * - image URL builder emits the provider-resolved id, not the Hub id + * - image URL builder throws a descriptive error for unmapped models + * - sttConfig exists and points at the live router host + * - every registered public model resolves through a provider the router serves + */ + +import { describe, it, expect } from "vitest"; +import huggingface from "../../open-sse/providers/registry/huggingface.js"; +import imageAdapter from "../../open-sse/handlers/imageProviders/huggingface.js"; + +const DEAD_HOST = "api-inference.huggingface.co"; +const LIVE_ROUTER = "router.huggingface.co"; + +// Providers the router actually forwards to. replicate/wavespeed/deepinfra appear +// in the Hub's inferenceProviderMapping but reject every router request with +// "Model not supported by provider ", so they must not be used here. +const ROUTABLE_PROVIDERS = new Set(["fal-ai", "hf-inference", "nscale", "together", "novita", "hyperbolic"]); + +const imageConfig = huggingface.imageConfig; +const modelMap = imageConfig.modelMap || {}; + +// modelMap values are either a bare path (text-to-image) or +// { path, task: "image-to-image" } for models that require a source image. +const mappingPath = (value) => (typeof value === "string" ? value : value.path); +const mappingTask = (value) => (typeof value === "string" ? "text-to-image" : value.task || "text-to-image"); + +describe("HuggingFace registry — legacy host removal", () => { + it("does not use the dead api-inference host for images", () => { + expect(imageConfig.baseUrl).not.toContain(DEAD_HOST); + }); + + it("points imageConfig at the live router host", () => { + expect(imageConfig.baseUrl).toContain(LIVE_ROUTER); + }); + + it("does not use the dead api-inference host for STT", () => { + expect(huggingface.sttConfig?.baseUrl).not.toContain(DEAD_HOST); + }); +}); + +describe("HuggingFace STT dispatch", () => { + it("declares an sttConfig so sttCore can dispatch", () => { + expect(huggingface.sttConfig).toBeDefined(); + }); + + it("uses the HuggingFace ASR wire format", () => { + expect(huggingface.sttConfig.format).toBe("huggingface-asr"); + }); + + it("authenticates with a bearer API key", () => { + expect(huggingface.sttConfig.authType).toBe("apikey"); + expect(huggingface.sttConfig.authHeader).toBe("bearer"); + }); + + it("advertises stt in serviceKinds", () => { + expect(huggingface.serviceKinds).toContain("stt"); + }); + + it("points sttConfig at the hf-inference model route", () => { + expect(huggingface.sttConfig.baseUrl).toBe("https://router.huggingface.co/hf-inference/models"); + }); +}); + +describe("HuggingFace image URL builder", () => { + it("routes FLUX.1-schnell through its fal-ai provider id", () => { + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell")).toBe( + "https://router.huggingface.co/fal-ai/fal-ai/flux/schnell" + ); + }); + + it("routes SDXL through its fal-ai provider id", () => { + expect(imageAdapter.buildUrl("stabilityai/stable-diffusion-xl-base-1.0")).toBe( + "https://router.huggingface.co/fal-ai/fal-ai/fast-sdxl" + ); + }); + + it("never leaks the dead host into a built URL", () => { + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell")).not.toContain(DEAD_HOST); + }); + + it("throws a descriptive error for a model with no provider mapping", () => { + expect(() => imageAdapter.buildUrl("some-org/not-mapped-model")).toThrow(/no HuggingFace router mapping/i); + }); + + it("lets a connection override the endpoint for a self-hosted model", () => { + const creds = { providerSpecificData: { baseUrl: "https://tgi.internal/" } }; + + expect(imageAdapter.buildUrl("my-org/my-tgi-model", creds)).toBe("https://tgi.internal/my-org/my-tgi-model"); + }); + + it("does not apply the router mapping when a custom endpoint is set", () => { + const creds = { providerSpecificData: { baseUrl: "https://tgi.internal" } }; + + // The custom endpoint knows its own model ids — the Hub id passes through verbatim. + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell", creds)).toBe( + "https://tgi.internal/black-forest-labs/FLUX.1-schnell" + ); + }); + + it("ignores a blank custom endpoint", () => { + expect(imageAdapter.buildUrl("black-forest-labs/FLUX.1-schnell", { providerSpecificData: { baseUrl: " " } })).toBe( + "https://router.huggingface.co/fal-ai/fal-ai/flux/schnell" + ); + }); + + it("rejects traversal or query injection in the model id on a custom endpoint", () => { + const creds = { providerSpecificData: { baseUrl: "https://tgi.internal" } }; + + for (const model of ["x/../../admin", "org//model", "model?x=1", "model#f"]) { + expect(() => imageAdapter.buildUrl(model, creds), model).toThrow(/invalid model ID/i); + } + }); +}); + +describe("HuggingFace registry model table", () => { + const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m])); + const imageModels = huggingface.models.filter((m) => m.kind === "image"); + const sttModels = huggingface.models.filter((m) => m.kind === "stt"); + + it("every image model is present in imageConfig.modelMap", () => { + for (const model of imageModels) { + expect(modelMap[model.id], `model ${model.id} is missing from imageConfig.modelMap`).toBeTruthy(); + } + }); + + it("every modelMap entry points at a routable provider", () => { + for (const [hubId, value] of Object.entries(modelMap)) { + const provider = String(mappingPath(value)).split("/")[0]; + expect(ROUTABLE_PROVIDERS.has(provider), `${hubId} -> unsupported provider ${provider}`).toBe(true); + } + }); + + it("every modelMap entry has a provider/model path shape", () => { + for (const [hubId, value] of Object.entries(modelMap)) { + expect(String(mappingPath(value)), `${hubId} has a malformed target`).toMatch(/^[a-z0-9-]+\/[A-Za-z0-9._/-]+$/); + } + }); + + it("does not advertise whisper-small, which has no live provider", () => { + expect(modelsById["openai/whisper-small"]).toBeUndefined(); + }); + + it("advertises whisper-large-v3-turbo as its STT replacement", () => { + expect(modelsById["openai/whisper-large-v3-turbo"]?.kind).toBe("stt"); + }); + + it("exposes the FLUX family image models", () => { + for (const id of [ + "black-forest-labs/FLUX.1-schnell", + "black-forest-labs/FLUX.1-dev", + "black-forest-labs/FLUX.1-Krea-dev", + "black-forest-labs/FLUX.1-Kontext-dev", + "black-forest-labs/FLUX.2-dev", + "black-forest-labs/FLUX.2-klein-9B", + "black-forest-labs/FLUX.2-klein-4B", + "black-forest-labs/FLUX.2-klein-base-9B", + "black-forest-labs/FLUX.2-klein-base-4B", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("exposes the Qwen-Image family", () => { + for (const id of [ + "Qwen/Qwen-Image", + "Qwen/Qwen-Image-2512", + "Qwen/Qwen-Image-Edit", + "Qwen/Qwen-Image-Edit-2509", + "Qwen/Qwen-Image-Edit-2511", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("exposes the Stable Diffusion family", () => { + for (const id of [ + "stabilityai/stable-diffusion-xl-base-1.0", + "stabilityai/stable-diffusion-3.5-large", + "stabilityai/stable-diffusion-3.5-large-turbo", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("exposes the HuggingFace ASR models", () => { + for (const id of ["openai/whisper-large-v3", "openai/whisper-large-v3-turbo"]) { + expect(modelsById[id]?.kind, `${id} should be registered as an stt model`).toBe("stt"); + } + }); + + it("exposes the remaining third-party image models", () => { + for (const id of [ + "tencent/HunyuanImage-3.0", + "Tongyi-MAI/Z-Image-Turbo", + "krea/Krea-2-Turbo", + "HiDream-ai/HiDream-I1-Fast", + "playgroundai/playground-v2.5-1024px-aesthetic", + "ideogram-ai/ideogram-4-fp8", + ]) { + expect(modelsById[id]?.kind, `${id} should be registered as an image model`).toBe("image"); + } + }); + + it("keeps the model table free of duplicates", () => { + const ids = huggingface.models.map((m) => m.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("keeps STT models free of image-only router mappings", () => { + for (const model of sttModels) { + expect(modelMap[model.id], `STT model ${model.id} should not be in the image model map`).toBeUndefined(); + } + }); +}); + +// The router is a switchboard in front of many providers; every Hub model that is +// `pipeline_tag: image-to-image` needs a source image, and the request shape differs +// from text-to-image: `inputs` carries the base64 source image and the prompt moves +// under `parameters.prompt`. Verified against +// https://huggingface.co/docs/inference-providers/tasks/image-to-image +describe("HuggingFace image-to-image models", () => { + const IMAGE_TO_IMAGE = [ + "black-forest-labs/FLUX.2-dev", + "black-forest-labs/FLUX.1-Kontext-dev", + "black-forest-labs/FLUX.2-klein-9B", + "black-forest-labs/FLUX.2-klein-4B", + "black-forest-labs/FLUX.2-klein-base-9B", + "black-forest-labs/FLUX.2-klein-base-4B", + "Qwen/Qwen-Image-Edit", + "Qwen/Qwen-Image-Edit-2509", + "Qwen/Qwen-Image-Edit-2511", + ]; + + it("marks every image-to-image model as such in modelMap", () => { + for (const hubId of IMAGE_TO_IMAGE) { + expect(mappingTask(modelMap[hubId]), `${hubId} must be declared image-to-image`).toBe("image-to-image"); + } + }); + + it("declares text-to-image as the default for the remaining image models", () => { + for (const [hubId, value] of Object.entries(modelMap)) { + if (IMAGE_TO_IMAGE.includes(hubId)) continue; + expect(mappingTask(value), `${hubId} should default to text-to-image`).toBe("text-to-image"); + } + }); + + it("sends the source image as inputs and the prompt under parameters", async () => { + const body = await imageAdapter.buildBody("Qwen/Qwen-Image-Edit", { + prompt: "make it snow", + image: "data:image/png;base64,AAAA", + }); + + expect(body.inputs).toBe("AAAA"); + expect(body.parameters).toEqual({ prompt: "make it snow" }); + }); + + it("accepts a source image given as a bare base64 payload", async () => { + const body = await imageAdapter.buildBody("black-forest-labs/FLUX.2-dev", { + prompt: "winter", + image: "AAAA", + }); + + expect(body.inputs).toBe("AAAA"); + }); + + it("accepts a source image given as an array", async () => { + const body = await imageAdapter.buildBody("Qwen/Qwen-Image-Edit-2509", { + prompt: "winter", + images: ["data:image/png;base64,BBBB"], + }); + + expect(body.inputs).toBe("BBBB"); + }); + + it("throws a descriptive error when an image-to-image model gets no source image", async () => { + await expect( + imageAdapter.buildBody("black-forest-labs/FLUX.1-Kontext-dev", { prompt: "winter" }) + ).rejects.toThrow(/requires a source image/i); + }); + + it("keeps the text-to-image shape prompt-only", async () => { + const body = await imageAdapter.buildBody("black-forest-labs/FLUX.1-schnell", { + prompt: "a lighthouse", + image: "data:image/png;base64,AAAA", + }); + + expect(body).toEqual({ inputs: "a lighthouse" }); + }); + + it("still throws for a model with no router mapping", () => { + expect(() => imageAdapter.buildUrl("some-org/unknown")).toThrow(/no HuggingFace router mapping/i); + }); +}); + +// The dashboard's GenericExampleCard only renders the source-image field when the +// selected model declares capabilities: ["edit"] (GenericExampleCard.js:47), and it +// then sends the value as `image`. Without the flag the edit models are unusable +// from the UI even though the adapter supports them. +describe("HuggingFace edit models reach the dashboard", () => { + const IMAGE_TO_IMAGE = ["black-forest-labs/FLUX.2-dev", "Qwen/Qwen-Image-Edit"]; + + it("declares the edit capability on image-to-image models", () => { + const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m])); + + for (const hubId of IMAGE_TO_IMAGE) { + expect(modelsById[hubId]?.capabilities, `${hubId} must declare the edit capability`).toContain("edit"); + } + }); + + it("keeps the capability on text-to-image models that do not take a source image", () => { + const modelsById = Object.fromEntries(huggingface.models.map((m) => [m.id, m])); + + expect(modelsById["black-forest-labs/FLUX.1-schnell"]?.capabilities || []).not.toContain("edit"); + }); +}); + +describe("HuggingFace registry prototype safety", () => { + it("does not resolve inherited object keys as models", () => { + // A plain-object map returns a truthy inherited value for these, which would + // build a URL like `/function Object() { [native code] }`. + for (const key of ["toString", "constructor", "__proto__", "hasOwnProperty"]) { + expect(() => imageAdapter.buildUrl(key)).toThrow(/no HuggingFace router mapping/i); + } + }); +}); + +describe("HuggingFace STT model parameters", () => { + const sttModels = huggingface.models.filter((m) => m.kind === "stt"); + + it("does not advertise a language parameter the ASR route cannot carry", () => { + // transcribeHuggingFace posts raw audio bytes and never reads formData, and the + // router's ASR payload has no `language` field — so a UI-declared "language" + // param is silently dropped. Declaring it lies to the dashboard. + for (const model of sttModels) { + expect(model.params, `${model.id} advertises an unusable language param`).toEqual([]); + } + }); +}); diff --git a/tests/unit/ollama-usage.test.js b/tests/unit/ollama-usage.test.js index fd8587e3..b3a5b5c2 100644 --- a/tests/unit/ollama-usage.test.js +++ b/tests/unit/ollama-usage.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ proxyAwareFetch: vi.fn(), @@ -44,6 +44,27 @@ const SAMPLE_USAGE = { }, }; +const SAMPLE_FREE_USAGE = { + activity: { + cost: "0.00000", + period: { + type: "last_4_weeks", + starting_at: "2026-08-24T00:00:00Z", + ending_at: "2026-09-18T15:03:00Z", + }, + models: [], + }, + limits: { + monthly: { + usage: 0.021, + models: [ + { name: "gpt-oss:120b", request_count: 6 }, + { name: "gemma4:31b", request_count: 6 }, + ], + }, + }, +}; + const SAMPLE_ME = { Plan: "max", }; @@ -103,6 +124,98 @@ describe("getUsageForProvider(ollama)", () => { expect(meOpts.headers["Content-Length"]).toBe("0"); }); + it("maps the free plan's monthly window", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE)) + .mockResolvedValueOnce(jsonResponse({ Plan: "free" })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Free"); + expect(Object.keys(usage.quotas)).toEqual(["Monthly"]); + expect(usage.quotas["Monthly"]).toMatchObject({ + used: 2, + total: 100, + remainingPercentage: 98, + unlimited: false, + }); + expect(usage.quotas["Monthly"].remaining).toBeUndefined(); + expect(usage.quotas["Monthly"].resetAt).toBeNull(); + }); + + describe("free plan monthly reset from signup date", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + async function monthlyResetAt(createdAt, now) { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE)) + .mockResolvedValueOnce(jsonResponse({ Plan: "free", CreatedAt: createdAt })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + return usage.quotas["Monthly"].resetAt; + } + + it("uses the signup day of the next month", async () => { + expect(await monthlyResetAt("2025-09-06T22:15:39.871687Z", "2026-09-18T15:03:00Z")) + .toBe("2026-10-06T22:15:39.000Z"); + }); + + it("stays in the current month when the signup day is still ahead", async () => { + expect(await monthlyResetAt("2026-09-18T09:50:49.514335Z", "2026-09-18T15:33:33Z")) + .toBe("2026-10-18T09:50:49.000Z"); + expect(await monthlyResetAt("2025-09-25T10:00:00Z", "2026-09-18T15:33:33Z")) + .toBe("2026-09-25T10:00:00.000Z"); + }); + + it("clamps the signup day to shorter months", async () => { + expect(await monthlyResetAt("2026-01-31T12:00:00Z", "2026-02-10T00:00:00Z")) + .toBe("2026-02-28T12:00:00.000Z"); + }); + + it("skips the reset when the plan is not free", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-18T15:03:00Z")); + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(SAMPLE_FREE_USAGE)) + .mockResolvedValueOnce(jsonResponse({ Plan: "pro", CreatedAt: "2025-09-06T22:15:39Z" })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + expect(usage.quotas["Monthly"].resetAt).toBeNull(); + }); + }); + + it("reports no limits when no known window is present", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse({ activity: {}, limits: {} })) + .mockResolvedValueOnce(jsonResponse({ Plan: "free" })); + + const usage = await getUsageForProvider({ + provider: "ollama", + apiKey: "k", + providerSpecificData: {}, + }); + + expect(usage.message).toMatch(/no usage limits/i); + expect(usage.quotas).toEqual({}); + }); + it("surfaces invalid key message on 401", async () => { proxyAwareFetch.mockResolvedValueOnce( jsonResponse({ error: "unauthorized" }, 401), diff --git a/tests/unit/openai-responses-usage-completed.test.js b/tests/unit/openai-responses-usage-completed.test.js new file mode 100644 index 00000000..fe750d02 --- /dev/null +++ b/tests/unit/openai-responses-usage-completed.test.js @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { FORMATS } from "../../open-sse/translator/formats.js"; +import { createSSETransformStreamWithLogger } from "../../open-sse/utils/stream.js"; + +/** + * Upstream chunks -> client Responses API events. + * + * The converter under test is openaiToOpenAIResponsesResponse(), reached through + * the registered OPENAI:OPENAI_RESPONSES pair. Without it, /v1/responses never + * reports usage and Responses clients (Codex CLI) keep their context gauge at 0, + * so they never auto-compact and eventually hit the upstream context limit. + * + * Signature is (targetFormat, sourceFormat, ...) — targetFormat is what the + * UPSTREAM speaks, sourceFormat is what the CLIENT speaks. + */ +async function runTransform(chunks, targetFormat = FORMATS.OPENAI) { + const encoder = new TextEncoder(); + const input = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join(""); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(input)); + controller.close(); + }, + }); + + const output = stream.pipeThrough( + createSSETransformStreamWithLogger( + targetFormat, + FORMATS.OPENAI_RESPONSES, + "deepseek", + null, + null, + "deepseek-flash", + ), + ); + + const reader = output.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; +} + +function completedEvents(output) { + return output + .split("\n") + .filter((l) => l.startsWith("data: ") && l.includes('"type":"response.completed"')); +} + +function completedResponse(output) { + const lines = completedEvents(output); + expect(lines.length, "expected exactly one response.completed").toBe(1); + return JSON.parse(lines[0].slice(6)).response; +} + +const TEXT_CHUNK = { + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 1700000000, + model: "deepseek-flash", + choices: [{ index: 0, delta: { role: "assistant", content: "好" } }], +}; + +const FINISH_CHUNK = { + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 1700000000, + model: "deepseek-flash", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], +}; + +// Usage-only trailer: `choices` is empty, exactly as OpenAI emits it when +// stream_options.include_usage is set. +const USAGE_ONLY_CHUNK = { + id: "chatcmpl-test", + object: "chat.completion.chunk", + created: 1700000000, + model: "deepseek-flash", + choices: [], + usage: { + prompt_tokens: 884, + completion_tokens: 37, + total_tokens: 921, + prompt_tokens_details: { cached_tokens: 256 }, + }, +}; + +const EXPECTED_USAGE = { + input_tokens: 884, + output_tokens: 37, + total_tokens: 921, + input_tokens_details: { cached_tokens: 256 }, +}; + +// Claude-shaped stream with NO usage anywhere: the only way the client gets a +// terminal event is the finish_reason branch, because the pivot never reaches +// flushEvents() with the terminal null chunk. +const CLAUDE_CHUNKS = [ + { type: "message_start", message: { id: "msg_1", model: "claude-x" } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" } }, + { type: "message_stop" }, +]; + +describe("OpenAI Responses usage on response.completed", () => { + it("maps usage reported on the finish chunk", async () => { + const output = await runTransform([ + TEXT_CHUNK, + { + ...FINISH_CHUNK, + usage: { + prompt_tokens: 884, + completion_tokens: 37, + total_tokens: 921, + prompt_tokens_details: { cached_tokens: 256 }, + completion_tokens_details: { reasoning_tokens: 12 }, + }, + }, + ]); + + expect(completedResponse(output).usage).toEqual({ + ...EXPECTED_USAGE, + output_tokens_details: { reasoning_tokens: 12 }, + }); + }); + + it("maps usage reported on a trailing usage-only chunk with empty choices", async () => { + const output = await runTransform([TEXT_CHUNK, FINISH_CHUNK, USAGE_ONLY_CHUNK]); + + expect(completedResponse(output).usage).toEqual(EXPECTED_USAGE); + }); + + it("still completes when the upstream reports no usage at all", async () => { + const output = await runTransform([TEXT_CHUNK, FINISH_CHUNK]); + + const response = completedResponse(output); + expect(response.status).toBe("completed"); + expect(response).not.toHaveProperty("usage"); + }); + + // Regression guard for the pivot: with a Claude upstream the converter runs as + // the second hop, translateResponse() drops the terminal null chunk before it + // reaches this converter, so flushEvents() never runs. Deferring completion + // there would leave the client without any terminal event. + it("completes on a pivoted stream whose upstream never reports usage", async () => { + const output = await runTransform(CLAUDE_CHUNKS, FORMATS.CLAUDE); + + const response = completedResponse(output); + expect(response.status).toBe("completed"); + }); +}); diff --git a/tests/unit/opencode-fingerprint.test.js b/tests/unit/opencode-fingerprint.test.js new file mode 100644 index 00000000..35036699 --- /dev/null +++ b/tests/unit/opencode-fingerprint.test.js @@ -0,0 +1,191 @@ +import { describe, it, expect } from "vitest"; +import { + applyFingerprintTools, + concealFingerprintToolNames, + appendMissingFingerprintTools, + fingerprintToolKey, + restoreToolNames, + takeRenamedToolNames, + OPENCODE_FINGERPRINT_TOOLS, +} from "open-sse/utils/opencodeFingerprint.js"; + +const CC_TOOLS = ["Task", "Bash", "Glob", "Grep", "Read", "Edit", "Write", "WebFetch"]; +const flat = (names) => names.map((name) => ({ type: "function", name })); +const chat = (names) => names.map((name) => ({ type: "function", function: { name } })); + +describe("opencodeFingerprint — request side", () => { + it("renames capitalised quartet members to lowercase", () => { + const body = { tools: flat(CC_TOOLS) }; + const map = applyFingerprintTools(body, true); + const names = body.tools.map((tool) => tool.name); + + expect(names).toContain("bash"); + expect(names).not.toContain("Bash"); + expect(names).toContain("Edit"); + expect(map.get("bash")).toBe("Bash"); + }); + + it("removes quartet case duplicates without dropping unrelated case variants", () => { + const body = { tools: flat(["Bash", "bash", "Glob", "grep", "Read", "Foo", "foo"]) }; + applyFingerprintTools(body, true); + + const names = body.tools.map((tool) => tool.name); + expect(names.filter((name) => name === "bash")).toHaveLength(1); + expect(names).toContain("Foo"); + expect(names).toContain("foo"); + }); + + it("preserves tool count when a complete quartet is only renamed", () => { + const body = { tools: flat(CC_TOOLS) }; + applyFingerprintTools(body, true); + expect(body.tools).toHaveLength(CC_TOOLS.length); + }); + + it("handles the nested chat shape without dropping .function", () => { + const body = { tools: chat(CC_TOOLS) }; + applyFingerprintTools(body, false); + + const names = body.tools.map((tool) => tool.function.name); + expect(names).toContain("bash"); + expect(names).not.toContain("Bash"); + expect(body.tools[1].function.name).toBe("bash"); + }); + + it("injects all four fingerprint tools when the body carries no tools", () => { + const body = { tools: [] }; + applyFingerprintTools(body, true); + + expect(body.tools.map((tool) => tool.name).sort()).toEqual([...OPENCODE_FINGERPRINT_TOOLS].sort()); + expect(body.tool_choice).toBe("auto"); + }); + + it("preserves the chat no-tool default tool_choice=none", () => { + const body = {}; + applyFingerprintTools(body, false); + + expect(body.tools.map((tool) => tool.function.name)).toEqual(OPENCODE_FINGERPRINT_TOOLS); + expect(body.tool_choice).toBe("none"); + }); + + it("does not invent a chat tool_choice when the caller already supplied tools", () => { + const body = { tools: chat(["Edit"]) }; + applyFingerprintTools(body, false); + expect(body.tool_choice).toBeUndefined(); + }); + + it("appends only genuinely missing quartet members", () => { + const body = { tools: flat(["Bash", "Read", "terminal"]) }; + applyFingerprintTools(body, true); + + const names = body.tools.map((tool) => tool.name); + expect(names).toContain("glob"); + expect(names).toContain("grep"); + expect(names).toContain("terminal"); + expect(body.tools).toHaveLength(5); + }); + + it("retargets flat tool_choice that points at a renamed tool", () => { + const body = { tools: flat(CC_TOOLS), tool_choice: { type: "function", name: "Bash" } }; + applyFingerprintTools(body, true); + expect(body.tool_choice.name).toBe("bash"); + }); + + it("retargets nested tool_choice that points at a renamed tool", () => { + const body = { + tools: chat(CC_TOOLS), + tool_choice: { type: "function", function: { name: "Read" } }, + }; + applyFingerprintTools(body, false); + expect(body.tool_choice.function.name).toBe("read"); + }); + + it("records the rename map against the body for the response side", () => { + const body = { tools: flat(CC_TOOLS) }; + const map = applyFingerprintTools(body, true); + expect(takeRenamedToolNames(body)).toBe(map); + }); + + it("never throws on malformed tools", () => { + for (const tools of [null, undefined, "nope", [null, 42, []], [{}, { name: "" }]]) { + expect(() => concealFingerprintToolNames(tools)).not.toThrow(); + expect(() => appendMissingFingerprintTools(tools, true)).not.toThrow(); + } + }); +}); + +describe("opencodeFingerprint — response side", () => { + const map = new Map([["bash", "Bash"], ["grep", "Grep"], ["read", "Read"]]); + + it("restores names in Claude content_block_start chunks", () => { + const chunk = { + type: "content_block_start", + content_block: { type: "tool_use", name: "bash", id: "t1" }, + }; + const out = restoreToolNames(chunk, map); + + expect(out.content_block.name).toBe("Bash"); + expect(chunk.content_block.name).toBe("bash"); + }); + + it("recursively restores streaming chunks inside arrays", () => { + const chunks = [{ + choices: [{ delta: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } }], + }]; + const out = restoreToolNames(chunks, map); + expect(out[0].choices[0].delta.tool_calls[0].function.name).toBe("Grep"); + }); + + it("restores names in Claude non-streaming bodies", () => { + const body = { type: "message", content: [{ type: "tool_use", name: "bash", input: {} }] }; + expect(restoreToolNames(body, map).content[0].name).toBe("Bash"); + }); + + it("restores names in Chat Completions message and delta shapes", () => { + const body = { + choices: [ + { message: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } }, + { delta: { tool_calls: [{ function: { name: "read", arguments: "{}" } }] } }, + ], + }; + const out = restoreToolNames(body, map); + + expect(out.choices[0].message.tool_calls[0].function.name).toBe("Grep"); + expect(out.choices[1].delta.tool_calls[0].function.name).toBe("Read"); + }); + + it("restores names in Responses final output items", () => { + const body = { output: [{ type: "function_call", name: "bash", call_id: "c1" }] }; + expect(restoreToolNames(body, map).output[0].name).toBe("Bash"); + }); + + it("restores names in Responses streaming output_item events", () => { + const event = { + type: "response.output_item.added", + item: { type: "function_call", name: "read", call_id: "c1" }, + }; + expect(restoreToolNames(event, map).item.name).toBe("Read"); + }); + + it("is a no-op without a map or with an empty map", () => { + const body = { choices: [{ message: { tool_calls: [{ function: { name: "bash" } }] } }] }; + expect(restoreToolNames(body, null)).toBe(body); + expect(restoreToolNames(body, new Map())).toBe(body); + }); + + it("leaves unknown tool names untouched", () => { + const body = { output: [{ type: "function_call", name: "Edit" }] }; + expect(restoreToolNames(body, map).output[0].name).toBe("Edit"); + }); +}); + +describe("fingerprintToolKey", () => { + it("maps quartet case/whitespace variants and rejects other tools", () => { + expect(fingerprintToolKey("Bash")).toBe("bash"); + expect(fingerprintToolKey(" bash ")).toBe("bash"); + expect(fingerprintToolKey("GLOB")).toBe("glob"); + expect(fingerprintToolKey("Read")).toBe("read"); + expect(fingerprintToolKey("Edit")).toBe(""); + expect(fingerprintToolKey("terminal")).toBe(""); + expect(fingerprintToolKey(null)).toBe(""); + }); +}); diff --git a/tests/unit/opencode-session.test.js b/tests/unit/opencode-session.test.js index 3e50b9fe..e8161f7e 100644 --- a/tests/unit/opencode-session.test.js +++ b/tests/unit/opencode-session.test.js @@ -277,39 +277,68 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => { expect(second).toBe(first); }); - it("cloaks free-tier requests with bash and read decoy tools", () => { + it("applies the full lowercase free-tier fingerprint quartet", () => { + const executor = getExecutor("opencode"); + + const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + }); + expect(chatNoTools.stream).toBe(true); + expect(chatNoTools.tool_choice).toBe("none"); + expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual([ + "bash", "glob", "grep", "read", + ]); + + const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + tools: [ + { type: "function", function: { name: "Bash", description: "Claude Code tool" } }, + { type: "function", function: { name: "Glob", description: "Claude Code tool" } }, + { type: "function", function: { name: "Grep", description: "Claude Code tool" } }, + { type: "function", function: { name: "Read", description: "Claude Code tool" } }, + ], + tool_choice: "auto", + }); + expect(chatWithTools.tool_choice).toBe("auto"); + expect(chatWithTools.tools.map((t) => t.function?.name)).toEqual([ + "bash", "glob", "grep", "read", + ]); + + const chatPartial = executor.transformRequest("nemotron-3-ultra-free", { + messages: [{ role: "user", content: "hi" }], + tools: [ + { type: "function", function: { name: "bash", description: "existing" } }, + { type: "function", function: { name: "read", description: "existing" } }, + ], + }); + expect(chatPartial.tools.map((t) => t.function?.name)).toEqual([ + "bash", "read", "glob", "grep", + ]); + expect(chatPartial.tools[0].function.description).toBe("existing"); +}); + + it("cloaks Muse Responses requests even when the client already supplies tools", () => { const executor = getExecutor("opencode"); - - // Case 1: no tools sent by client -> injects bash + read with tool_choice none - const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", { - messages: [{ role: "user", content: "hi" }], - }); - expect(chatNoTools.stream).toBe(true); - expect(chatNoTools.tool_choice).toBe("none"); - expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual(["bash", "read"]); - - // Case 2: external CLI tools (e.g. Claude Code Bash) -> preserves Bash, appends read - const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", { - messages: [{ role: "user", content: "hi" }], - tools: [{ type: "function", function: { name: "Bash", description: "Claude Code tool" } }], + const transformed = executor.transformRequest("muse-spark-1.3-contributor-free(xhigh)", { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [{ + type: "function", + name: "zcode_search", + description: "client-provided tool", + parameters: { type: "object", properties: {} }, + }], tool_choice: "auto", - }); - expect(chatWithTools.tool_choice).toBe("auto"); - const names = chatWithTools.tools.map((t) => t.function?.name); - expect(names).toContain("Bash"); + reasoning_effort: "xhigh", + }, true, {}); + + expect(transformed.stream).toBe(true); + expect(transformed.reasoning?.effort).toBe("xhigh"); + const names = transformed.tools.map((tool) => tool.name); + expect(names).toContain("zcode_search"); expect(names).toContain("bash"); expect(names).toContain("read"); - - // Case 3: already has both bash and read -> do not insert anything - const chatFull = executor.transformRequest("nemotron-3-ultra-free", { - messages: [{ role: "user", content: "hi" }], - tools: [ - { type: "function", function: { name: "bash", description: "existing" } }, - { type: "function", function: { name: "read", description: "existing" } }, - ], - }); - expect(chatFull.tools.length).toBe(2); - expect(chatFull.tools[0].function.description).toBe("existing"); + expect(names.filter((name) => name === "bash")).toHaveLength(1); + expect(names.filter((name) => name === "read")).toHaveLength(1); }); it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => { diff --git a/tests/unit/opencode-zen-models.test.js b/tests/unit/opencode-zen-models.test.js new file mode 100644 index 00000000..a5e8f0f4 --- /dev/null +++ b/tests/unit/opencode-zen-models.test.js @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { resolveTransport } from "../../open-sse/services/provider.js"; + +// Chat-only models (no /messages, no /responses support on opencode-zen) +const CHAT_ONLY = ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", + "glm-5.3-flash", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", + "kimi-k3", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", + "big-pickle", "deepseek-v4-flash-free", + "mimo-v2.6-flash-free", "mimo-v2.5-free", "ling-3.0-flash-fin-free", "nemotron-3-ultra-free", "nemotron-3.5-lightning-free"]; +// Models that also expose the Anthropic /messages endpoint +const CLAUDE_CAPABLE = ["claude-fable-5", "claude-fable-5-1", "claude-opus-5", + "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", + "claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-sonnet-4", + "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "union-alpha"]; +// Models that also expose the OpenAI /responses endpoint +const RESPONSES_CAPABLE = ["gpt-6-astra", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", + "gpt-5.5", "gpt-5.5-pro", + "gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano", + "gpt-5.3-codex-spark", "gpt-5.3-codex", + "gpt-5.2", "gpt-5.2-codex", + "gpt-5.1", "gpt-5.1-codex-max", "gpt-5.1-codex", "gpt-5.1-codex-mini", + "gpt-5", "gpt-5-codex", "gpt-5-nano", + "grok-build-0.1", "grok-4.6", "grok-4.5", + "muse-spark-1.3", "muse-spark-1.2", + "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor-free"]; + +// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched +// transport only when the model declares support for that sourceFormat. +function pickTransport(provider, sourceFormat, alias, model) { + const supported = getModelSupportedFormats(alias, model); + const rt = resolveTransport(provider, sourceFormat); + return supported?.includes(sourceFormat) ? rt : null; +} + +describe("OpenCode Zen model catalog", () => { + it("matches the documented model IDs", () => { + const ids = (PROVIDER_MODELS["ocz"] || []).map((m) => m.id); + expect(ids).toContain("muse-spark-1.3-contributor-free"); + expect(ids).toContain("gpt-5.5"); + expect(ids).toContain("claude-opus-5"); + expect(ids).toContain("kimi-k3"); + expect(ids).toContain("deepseek-v4-pro"); + expect(ids.length).toBeGreaterThan(60); + }); +}); + +describe("OpenCode Zen per-model supportedFormats", () => { + it("declares [claude] for Claude + Qwen + union-alpha models", () => { + for (const m of CLAUDE_CAPABLE) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["claude"]); + } + }); + + it("declares [openai-responses] for GPT/Grok/Spark responses models", () => { + for (const m of RESPONSES_CAPABLE) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["openai-responses"]); + } + }); + + it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => { + for (const m of CHAT_ONLY) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["openai"]); + } + }); +}); + +describe("OpenCode Zen multi-endpoint transports", () => { + it("declares openai / claude / openai-responses transports", () => { + const formats = (PROVIDERS["opencode-zen"].transports || []).map((t) => t.format); + expect(formats).toEqual(["openai", "claude", "openai-responses"]); + }); + + it("resolveTransport picks the endpoint matching the client sourceFormat", () => { + expect(resolveTransport("opencode-zen", "claude").baseUrl).toBe("https://opencode.ai/zen/v1/messages"); + expect(resolveTransport("opencode-zen", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/v1/responses"); + expect(resolveTransport("opencode-zen", "openai").baseUrl).toBe("https://opencode.ai/zen/v1/chat/completions"); + }); + + it("uses x-api-key + anthropicVersion on the claude transport", () => { + const t = resolveTransport("opencode-zen", "claude"); + expect(t.auth.header).toBe("x-api-key"); + expect(t.auth.anthropicVersion).toBe(true); + }); +}); + +describe("OpenCode Zen per-model transport guard (chatCore logic)", () => { + it("routes MiniMax/Qwen + claude-format client to /messages", () => { + for (const m of CLAUDE_CAPABLE) { + expect(pickTransport("opencode-zen", "claude", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/messages"); + } + }); + + it("does NOT route chat-only models to /messages on a claude-format request", () => { + for (const m of CHAT_ONLY) { + expect(pickTransport("opencode-zen", "claude", "ocz", m)).toBeNull(); + } + }); + + it("routes DeepSeek + responses-format client to /responses", () => { + for (const m of RESPONSES_CAPABLE) { + expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/responses"); + } + }); + + it("routes Muse Spark (responses-only) to /responses, never to /messages", () => { + for (const m of ["muse-spark-1.2", "muse-spark-1.3", "muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free", "grok-4.6", "gpt-5.6-luna"]) { + expect(getModelSupportedFormats("ocz", m)).toEqual(["openai-responses"]); + expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)?.baseUrl).toBe("https://opencode.ai/zen/v1/responses"); + expect(pickTransport("opencode-zen", "claude", "ocz", m)).toBeNull(); + expect(pickTransport("opencode-zen", "openai", "ocz", m)).toBeNull(); + } + }); + + it("does NOT route MiniMax (no responses support) to /responses", () => { + for (const m of CLAUDE_CAPABLE) { + expect(pickTransport("opencode-zen", "openai-responses", "ocz", m)).toBeNull(); + } + }); +}); diff --git a/tests/unit/param-support.test.js b/tests/unit/param-support.test.js index c54d132b..f19d0e78 100644 --- a/tests/unit/param-support.test.js +++ b/tests/unit/param-support.test.js @@ -52,4 +52,48 @@ describe("stripUnsupportedParams", () => { expect(body.max_tokens).toBe(64000); }); + + it("drops replayed reasoning fields from assistant messages for strict providers", () => { + const makeBody = () => ({ + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: "hello", + reasoning_content: "thinking...", + reasoning: "thinking...", + reasoning_details: [{ text: "thinking..." }], + tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }], + }, + { role: "user", content: "again", reasoning_content: "user-side field stays" }, + ], + }); + + for (const [provider, model] of [ + ["groq", "openai/gpt-oss-120b"], + ["mistral", "codestral-latest"], + ["cerebras", "gpt-oss-120b"], + ]) { + const body = makeBody(); + stripUnsupportedParams(provider, model, body); + expect(body.messages[1]).toEqual({ + role: "assistant", + content: "hello", + tool_calls: [{ id: "c1", type: "function", function: { name: "f", arguments: "{}" } }], + }); + // only assistant turns are touched + expect(body.messages[2].reasoning_content).toBe("user-side field stays"); + } + }); + + it("leaves reasoning fields alone for providers that accept or require them", () => { + const body = { + messages: [{ role: "assistant", content: "hello", reasoning_content: "thinking..." }], + }; + + stripUnsupportedParams("deepseek", "deepseek-reasoner", body); + stripUnsupportedParams("openrouter", "nvidia/nemotron-3-ultra-550b-a55b:free", body); + + expect(body.messages[0].reasoning_content).toBe("thinking..."); + }); }); diff --git a/tests/unit/qoder-billing.test.js b/tests/unit/qoder-billing.test.js index f97a1293..4900f2df 100644 --- a/tests/unit/qoder-billing.test.js +++ b/tests/unit/qoder-billing.test.js @@ -82,6 +82,148 @@ describe("wrapQoderSSE billing detection", () => { expect(wrapped.ok).toBe(false); }); + it("returns 403 response when first frame is billing block (code 110 string)", async () => { + const billingEnv = JSON.stringify({ + statusCodeValue: 403, + body: '{"code":"110","message":"Billing daily count exceeded"}', + }); + const upstream = `data: ${billingEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + expect(wrapped.status).toBe(403); + expect(wrapped.ok).toBe(false); + const json = await wrapped.json(); + expect(json.error.message).toContain("Billing daily count exceeded"); + }); + + it("returns 403 response when first frame is billing block (code 110 numeric)", async () => { + const billingEnv = JSON.stringify({ + statusCodeValue: 403, + body: '{"code":110,"message":"Billing daily count exceeded"}', + }); + const upstream = `data: ${billingEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + expect(wrapped.status).toBe(403); + expect(wrapped.ok).toBe(false); + }); + it("returns 403 response when statusCodeValue is string \"403\" (code 110)", async () => { + const billingEnv = JSON.stringify({ + statusCodeValue: "403", + body: '{"code":"110","message":"Billing daily count exceeded"}', + }); + const upstream = `data: ${billingEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + expect(wrapped.status).toBe(403); + expect(wrapped.ok).toBe(false); + const json = await wrapped.json(); + expect(json.error.message).toContain("Billing daily count exceeded"); + }); + + it("emits structured 403 error chunk for object-body billing after a data frame (peek miss)", async () => { + const okEnv = JSON.stringify({ + statusCodeValue: 200, + body: JSON.stringify({ choices: [{ delta: { content: "hi" } }] }), + }); + const billingEnv = JSON.stringify({ + statusCodeValue: 403, + body: { code: "110", message: "Billing daily count exceeded" }, + }); + const upstream = `data: ${okEnv}\n\ndata: ${billingEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + const reader = wrapped.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + buf += decoder.decode(); + + expect(buf).not.toContain("[qoder error"); + const errLine = buf.split("\n").find((l) => l.includes('"error"')); + expect(errLine).toBeDefined(); + const errChunk = JSON.parse(errLine.slice(5).trim()); + expect(errChunk.error.status).toBe(403); + expect(errChunk.error.message).toContain("Billing daily count exceeded"); + expect(errChunk.choices).toBeUndefined(); + }); + + + it("does not treat legitimate assistant text mentioning code 110 as billing", async () => { + const inner = JSON.stringify({ + choices: [{ delta: { content: "error 110 means billing daily count exceeded in docs" } }], + }); + const successEnv = JSON.stringify({ statusCodeValue: 200, body: inner }); + const upstream = `data: ${successEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + expect(wrapped.status).toBe(200); + const reader = wrapped.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + buf += decoder.decode(); + + expect(buf).toContain("billing daily count exceeded"); + expect(buf).not.toContain("[qoder error"); + }); + + it("emits structured 403 error chunk for billing envelope after a data frame (peek miss)", async () => { + const okEnv = JSON.stringify({ + statusCodeValue: 200, + body: JSON.stringify({ choices: [{ delta: { content: "hi" } }] }), + }); + const billingEnv = JSON.stringify({ + statusCodeValue: 403, + body: '{"code":"110","message":"Billing daily count exceeded"}', + }); + const upstream = `data: ${okEnv}\n\ndata: ${billingEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + const reader = wrapped.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + buf += decoder.decode(); + + expect(buf).not.toContain("[qoder error"); + const errLine = buf.split("\n").find((l) => l.includes('"error"')); + expect(errLine).toBeDefined(); + const errChunk = JSON.parse(errLine.slice(5).trim()); + expect(errChunk.error.status).toBe(403); + expect(errChunk.error.message).toContain("Billing daily count exceeded"); + }); + + it("emits structured 403 error chunk for object-body billing envelope (peek miss)", async () => { + const billingEnv = JSON.stringify({ + statusCodeValue: 403, + body: { code: "110", message: "Billing daily count exceeded" }, + }); + const upstream = `data: ${billingEnv}\n\n`; + + const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/qfmodel"); + + expect(wrapped.status).toBe(403); + }); + it("returns 403 response when first frame has pricingUrl", async () => { const billingEnv = JSON.stringify({ statusCodeValue: 402, @@ -94,7 +236,7 @@ describe("wrapQoderSSE billing detection", () => { expect(wrapped.status).toBe(403); }); - it("passes through normal errors (non-billing) as wrapped SSE", async () => { + it("returns non-billing errors with their upstream HTTP status", async () => { const errorEnv = JSON.stringify({ statusCodeValue: 500, body: "Internal server error", @@ -103,22 +245,11 @@ describe("wrapQoderSSE billing detection", () => { const wrapped = await wrapQoderSSE(makeResponse([upstream]), "qoder/ultimate"); - // Normal error: still 200 response, error text in SSE body - expect(wrapped.status).toBe(200); - expect(wrapped.ok).toBe(true); - - const reader = wrapped.body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - } - buf += decoder.decode(); - - expect(buf).toContain("[qoder error 500"); - expect(buf).toContain("data: [DONE]"); + expect(wrapped.status).toBe(500); + expect(wrapped.ok).toBe(false); + expect(await wrapped.json()).toEqual({ + error: { message: "Internal server error", code: 500 }, + }); }); it("passes through successful responses unchanged", async () => { diff --git a/tests/unit/qoder-proxy-replay.test.js b/tests/unit/qoder-proxy-replay.test.js new file mode 100644 index 00000000..1a3ff0c6 --- /dev/null +++ b/tests/unit/qoder-proxy-replay.test.js @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../open-sse/services/qoderModels.js", () => ({ + getQoderModelConfig: vi.fn(async () => ({ key: "auto", max_output_tokens: 32 })), + resolveQoderModels: vi.fn(), + isQoderPat: () => false, + resolveQoderCredentials: vi.fn(), +})); + +const request = { + model: "auto", + body: { messages: [{ role: "user", content: "hello" }], max_tokens: 32 }, + stream: true, + credentials: { + accessToken: "dt-test-token", + providerSpecificData: { userId: "test-user", machineId: "test-machine" }, + }, +}; + +function success() { + return new Response('data: {"statusCodeValue":200,"body":"[DONE]"}\n\n', { + headers: { "Content-Type": "text/event-stream" }, + }); +} + +async function loadExecutor(fetchMock, useProxy = true) { + vi.resetModules(); + for (const key of ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]) { + vi.stubEnv(key, ""); + } + if (useProxy) vi.stubEnv("HTTPS_PROXY", "http://proxy.test:3128"); + // Exercise the real proxyAwareFetch: it captures fetch when imported. + vi.stubGlobal("fetch", fetchMock); + const { QoderExecutor } = await import("../../open-sse/executors/qoder.js"); + return new QoderExecutor(); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +describe("Qoder signed inference transport", () => { + it.each([null, { strictProxy: false }])("does not replay a signed POST after proxy response loss (%j)", async (proxyOptions) => { + const seen = new Set(); + const fetchMock = vi.fn(async (_url, options) => { + const authorization = options.headers.Authorization; + if (seen.has(authorization)) { + return new Response('data: {"statusCodeValue":403,"body":"{\\"code\\":\\"103\\",\\"message\\":\\"Duplicate request\\"}"}\n\n'); + } + seen.add(authorization); + throw new TypeError("response lost after upstream accepted request"); + }); + const executor = await loadExecutor(fetchMock); + await expect(executor.execute({ ...request, proxyOptions })).rejects.toThrow("response lost"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].dispatcher).toBeDefined(); + if (proxyOptions) expect(proxyOptions.strictProxy).toBe(false); + }); + + it("generates a fresh COSY identity when the caller retries after transport failure", async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new TypeError("response lost")) + .mockResolvedValueOnce(success()); + const executor = await loadExecutor(fetchMock); + await expect(executor.execute(request)).rejects.toThrow("response lost"); + const result = await executor.execute(request); + expect(result.response.ok).toBe(true); + await result.response.text(); + expect(fetchMock).toHaveBeenCalledTimes(2); + const ids = fetchMock.mock.calls.map(([, options]) => JSON.parse( + Buffer.from(options.headers.Authorization.split(".")[1], "base64").toString(), + ).requestId); + expect(ids[0]).not.toBe(ids[1]); + }); + + it.each([true, false])("still supports successful inference with proxy=%s", async (useProxy) => { + const fetchMock = vi.fn(async () => success()); + const executor = await loadExecutor(fetchMock, useProxy); + const result = await executor.execute(request); + expect(result.response.ok).toBe(true); + await result.response.text(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(!!fetchMock.mock.calls[0][1].dispatcher).toBe(useProxy); + }); + + it("preserves caller cancellation without replaying the request", async () => { + const controller = new AbortController(); + const fetchMock = vi.fn(async (_url, options) => { + controller.abort(); + throw options.signal.reason; + }); + const executor = await loadExecutor(fetchMock); + await expect(executor.execute({ ...request, signal: controller.signal })).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/qoder-stream-errors.test.js b/tests/unit/qoder-stream-errors.test.js new file mode 100644 index 00000000..60b316b0 --- /dev/null +++ b/tests/unit/qoder-stream-errors.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { __test__ } from "../../open-sse/executors/qoder.js"; + +const { wrapQoderSSE } = __test__; +const duplicate = '{"code":"103","message":"Duplicate request"}'; +const frame = (statusCodeValue, body) => `data: ${JSON.stringify({ statusCodeValue, body })}\n\n`; + +function upstream(chunks, { keepOpen = false } = {}) { + const cancel = vi.fn(); + const response = new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk)); + if (!keepOpen) controller.close(); + }, + cancel, + })); + return { response, cancel }; +} + +describe("Qoder first-frame errors", () => { + it.each([ + ["one chunk", [frame(403, duplicate)]], + ["fragmented frame", [frame(403, duplicate).slice(0, 35), frame(403, duplicate).slice(35)]], + ["heartbeat prefix", [": keepalive\r\n\r\n", frame(403, duplicate)]], + ["prefix and frame in one chunk", [": keepalive\n\nevent: message\n" + frame(403, duplicate)]], + ["EOF without newline", [frame(403, duplicate).trimEnd()]], + ["object body", [frame(403, JSON.parse(duplicate))]], + ])("surfaces duplicate-request errors as HTTP 403: %s", async (_name, chunks) => { + const { response } = upstream(chunks); + const wrapped = await wrapQoderSSE(response, "qoder/kmodel_latest"); + expect(wrapped.status).toBe(403); + expect(wrapped.ok).toBe(false); + expect(wrapped.headers.get("content-type")).toBe("application/json"); + const body = await wrapped.json(); + expect(body.error.message).toBe(duplicate); + expect(body).not.toHaveProperty("choices"); + }); + + it("cancels the upstream keepalive immediately after an error", async () => { + const { response, cancel } = upstream([": keepalive\n\n", frame(403, duplicate)], { keepOpen: true }); + const wrapped = await wrapQoderSSE(response, "qoder/kmodel_latest"); + expect(wrapped.status).toBe(403); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it.each([401, 429, 500, 503])("preserves non-billing HTTP status %s", async (status) => { + const { response } = upstream([frame(status, "upstream failure")]); + const wrapped = await wrapQoderSSE(response, "qoder/auto"); + expect(wrapped.status).toBe(status); + expect((await wrapped.json()).error.message).toBe("upstream failure"); + }); + + it.each([0, 302, 600, 403.5])("maps invalid error status %s to 502", async (status) => { + const { response } = upstream([frame(status, "invalid upstream status")]); + const wrapped = await wrapQoderSSE(response, "qoder/auto"); + expect(wrapped.status).toBe(502); + }); + + it("replays successful frames after a heartbeat without losing or duplicating content", async () => { + const first = JSON.stringify({ choices: [{ delta: { content: "hello" } }] }); + const second = JSON.stringify({ choices: [{ delta: { content: "world" } }] }); + const { response } = upstream([": keepalive\n\n", frame(200, first) + frame(200, second) + "data: [DONE]\n\n"]); + const wrapped = await wrapQoderSSE(response, "qoder/auto"); + expect(wrapped.status).toBe(200); + expect(await wrapped.text()).toBe(`data: ${first}\n\ndata: ${second}\n\ndata: [DONE]\n\n`); + }); + + it("starts forwarding success without waiting for the upstream to close", async () => { + const inner = JSON.stringify({ choices: [{ delta: { content: "hello" } }] }); + const { response, cancel } = upstream([": keepalive\n\n", frame(200, inner)], { keepOpen: true }); + const wrapped = await wrapQoderSSE(response, "qoder/auto"); + const reader = wrapped.body.getReader(); + try { + const { value } = await reader.read(); + expect(new TextDecoder().decode(value)).toBe(`data: ${inner}\n\n`); + } finally { + await reader.cancel(); + } + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/qoder.test.js b/tests/unit/qoder.test.js index ea45ac61..ce7edd67 100644 --- a/tests/unit/qoder.test.js +++ b/tests/unit/qoder.test.js @@ -506,14 +506,14 @@ describe("wrapQoderSSE", () => { // Regression for review finding #3: chunks could leak past [DONE] when // the success branch had no doneEmitted guard. We synthesize an error - // envelope (which sets doneEmitted=true) followed by a valid envelope + // envelope after content (which sets doneEmitted=true), followed by a valid envelope // and assert the second envelope is NOT forwarded. it("does not forward chunks after [DONE] has been emitted", async () => { const errorEnv = JSON.stringify({ statusCodeValue: 500, body: "boom" }); const validInner = JSON.stringify({ choices: [{ delta: { content: "leak" } }] }); const validEnv = JSON.stringify({ statusCodeValue: 200, body: validInner }); const wrapped = await wrapQoderSSE( - makeResponse([`data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]), + makeResponse([envelope(JSON.stringify({ choices: [{ delta: { content: "hi" } }] })) + `data: ${errorEnv}\n\ndata: ${validEnv}\n\n`]), "qoder/auto", ); const out = await drain(wrapped); @@ -539,12 +539,13 @@ describe("wrapQoderSSE", () => { expect(() => JSON.parse(dataLine.slice("data: ".length))).not.toThrow(); }); - it("upstream error envelope produces an error chunk + [DONE]", async () => { + it("upstream first-frame error envelope produces an HTTP error", async () => { const env = JSON.stringify({ statusCodeValue: 503, body: "service unavailable" }); const wrapped = await wrapQoderSSE(makeResponse([`data: ${env}\n\n`]), "qoder/lite"); - const out = await drain(wrapped); - expect(out).toContain("[qoder error 503"); - expect(out).toContain("data: [DONE]\n\n"); + expect(wrapped.status).toBe(503); + expect(await wrapped.json()).toEqual({ + error: { message: "service unavailable", code: 503 }, + }); }); it("non-ok responses are returned unchanged (no transform)", async () => { @@ -651,6 +652,11 @@ describe("qoderInferenceBase", () => { expect(qoderInferenceBase({ accessToken: "jt-abc" })).toContain("api2.qoder.sh"); expect(qoderInferenceBase({ accessToken: "dt-abc" })).toContain("api3.qoder.sh"); }); + + it("serves every token kind from the CN gateway for the qoder-cn region", () => { + expect(qoderInferenceBase({ accessToken: "jt-abc" }, "cn")).toContain("gateway.qoder.com.cn"); + expect(qoderInferenceBase({ accessToken: "dt-abc" }, "cn")).toContain("gateway.qoder.com.cn"); + }); }); describe("rewriteQoderMessageAttachments", () => { diff --git a/tests/unit/rtk-cursor-pretranslate.test.js b/tests/unit/rtk-cursor-pretranslate.test.js new file mode 100644 index 00000000..601fcfec --- /dev/null +++ b/tests/unit/rtk-cursor-pretranslate.test.js @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { executeMock } = vi.hoisted(() => ({ + executeMock: vi.fn(), +})); + +vi.mock("../../open-sse/executors/index.js", () => ({ + getExecutor: () => ({ + noAuth: true, + execute: executeMock, + }), +})); + +vi.mock("../../open-sse/utils/requestLogger.js", () => ({ + createRequestLogger: async () => ({ + logClientRawRequest: vi.fn(), + logRawRequest: vi.fn(), + logTargetRequest: vi.fn(), + logProviderResponse: vi.fn(), + logConvertedResponse: vi.fn(), + logError: vi.fn(), + }), +})); + +vi.mock("../../open-sse/utils/stream.js", () => ({ + COLORS: { red: "", reset: "" }, + createPassthroughStreamWithLogger: vi.fn(() => new TransformStream()), +})); + +vi.mock("@/lib/usageDb.js", () => ({ + trackPendingRequest: vi.fn(), + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), +})); + +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.js"); + +function makeLongDiff() { + const lines = ["diff --git a/foo.js b/foo.js", "index abc..def 100644", "--- a/foo.js", "+++ b/foo.js", "@@ -1,3 +1,200 @@"]; + for (let i = 0; i < 200; i++) lines.push(`+added line ${i} UNIQUE_PADDING_${i} ${"x".repeat(20)}`); + return lines.join("\n"); +} + +describe("token savers on Cursor (pre-translate RTK)", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(async (url, init) => { + if (String(url).includes("/v1/compress")) { + const payload = JSON.parse(init.body); + return new Response(JSON.stringify({ + messages: payload.messages, + tokens_before: 8000, + tokens_after: 2500, + tokens_saved: 5500, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + executeMock.mockResolvedValue({ + response: new Response(JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion", + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop", index: 0 }], + }), { status: 200, headers: { "content-type": "application/json" } }), + url: "https://api2.cursor.sh/agent", + headers: {}, + transformedBody: null, + }); + }); + + it("compresses role:tool git diffs before openai→cursor rewrite, then injects Headroom/Caveman/Ponytail", async () => { + const diff = makeLongDiff(); + const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), line: vi.fn() }; + + await handleChatCore({ + body: { + model: "cu/default", + stream: false, + messages: [ + { role: "system", content: "hi" }, + { role: "user", content: "run git diff" }, + { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "Bash", arguments: JSON.stringify({ command: "git diff" }) } }], + }, + { role: "tool", tool_call_id: "call_1", content: diff }, + { role: "user", content: "summarize" }, + ], + }, + modelInfo: { provider: "cursor", model: "default" }, + credentials: { apiKey: "test-key", providerSpecificData: {} }, + log, + connectionId: "test-conn", + rtkEnabled: true, + headroomEnabled: true, + headroomUrl: "http://localhost:8787", + cavemanEnabled: true, + cavemanLevel: "full", + ponytailEnabled: true, + ponytailLevel: "full", + clientRawRequest: { + endpoint: "/v1/chat/completions", + body: { model: "cu/default" }, + headers: { accept: "application/json" }, + }, + }); + + expect(executeMock).toHaveBeenCalled(); + const dispatched = executeMock.mock.calls[0][0].body; + const blob = JSON.stringify(dispatched.messages); + + expect(dispatched.messages.some((m) => m.role === "tool")).toBe(false); + expect(blob).toContain(""); + expect(blob).toContain("lines truncated"); + expect(blob).not.toContain("UNIQUE_PADDING_150"); + expect(blob).toContain("lazy senior developer"); + expect(blob).toMatch(/Respond like a caveman|drop filler|ACTIVE EVERY RESPONSE/i); + + expect(global.fetch).toHaveBeenCalledWith( + "http://localhost:8787/v1/compress", + expect.any(Object) + ); + + const xf = log.line.mock.calls.find((c) => c[1] === "⚙"); + expect(xf, "expected ⚙ saver log").toBeTruthy(); + expect(xf[2]).toContain("RTK:"); + expect(xf[2]).toContain("CAVEMAN:full"); + expect(xf[2]).toContain("PONYTAIL:full"); + }); +}); diff --git a/tests/unit/thinking-budget-max-level.test.js b/tests/unit/thinking-budget-max-level.test.js new file mode 100644 index 00000000..c098ab91 --- /dev/null +++ b/tests/unit/thinking-budget-max-level.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { budgetToLevel } from "../../open-sse/translator/concerns/thinking.js"; +import { applyThinking } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +// Reverse map must be able to reach "max": LEVEL_TO_BUDGET.max = 128000 and +// xhigh = 32768, so the xhigh/max threshold is their midpoint (80384). +// Previously any budget > 28672 collapsed to "xhigh", making "max" +// unreachable from Claude Code budget_tokens — its default thinking budget +// (MAX_THINKING_TOKENS) could never produce effort "max". +describe("budgetToLevel reaches max tier", () => { + it("budget 98304 → \"max\"", () => { + expect(budgetToLevel(98304)).toBe("max"); + }); + + it("budget 128000 → \"max\"", () => { + expect(budgetToLevel(128000)).toBe("max"); + }); + + it("budget 80385 → \"max\" (just above midpoint)", () => { + expect(budgetToLevel(80385)).toBe("max"); + }); + + it("budget 80384 → \"xhigh\" (midpoint still xhigh)", () => { + expect(budgetToLevel(80384)).toBe("xhigh"); + }); + + it("budget 31999 stays \"xhigh\"", () => { + expect(budgetToLevel(31999)).toBe("xhigh"); + }); +}); + +describe("applyThinking (openai-responses): large budgets map to max effort", () => { + it("budget 98304 → reasoning_effort \"max\" for gpt-5.6-sol (openai wire)", () => { + const body = { thinking: { type: "enabled", budget_tokens: 98304 } }; + const out = applyThinking(FORMATS.OPENAI_RESPONSES, "gpt-5.6-sol", body, "codex"); + expect(out?.reasoning_effort).toBe("max"); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index ead63892..99acfad8 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -14,7 +14,7 @@ vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ const load = () => import("../../open-sse/services/usage.js"); const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", - "qoder", "iflow", "ollama", "glm", "glm-cn", + "qoder", "qoder-cn", "iflow", "ollama", "glm", "glm-cn", "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", "deepseek", "opencode-go", "zed", "commandcode", ]; diff --git a/tests/unit/xiaomi-mimo-executor.test.js b/tests/unit/xiaomi-mimo-executor.test.js index 10c6a520..1a3c790c 100644 --- a/tests/unit/xiaomi-mimo-executor.test.js +++ b/tests/unit/xiaomi-mimo-executor.test.js @@ -1,6 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { XiaomiMimoExecutor, __test__ } from "../../open-sse/executors/xiaomi-mimo.js"; import { getExecutor } from "../../open-sse/executors/index.js"; +import * as mimoAccount from "../../open-sse/shared/mimoAccount.js"; const { bareModel, COOKIE_KEY } = __test__; @@ -17,22 +18,52 @@ describe("xiaomi-mimo executor", () => { expect(getExecutor("xiaomi-mimo")).toBeInstanceOf(XiaomiMimoExecutor); }); - it("routes Preview models to the account-service route regardless of transport", () => { - const expected = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions"; - expect(ex.buildUrl("mimo-x-pro-preview", true, 0, OPENAI_T)).toBe(expected); - expect(ex.buildUrl("mimo-x-pro-preview", true, 0, CLAUDE_T)).toBe(expected); - // body.model arrives as `xiaomi/` via upstreamModelId - expect(ex.buildUrl("xiaomi/mimo-x-flash-preview", true, 0, OPENAI_T)).toBe(expected); - }); - it("keeps the sourceFormat-matched endpoint for cloud models", () => { - // Regression: a Claude client must reach /anthropic/v1/messages, not /v1/chat/completions. expect(ex.buildUrl("mimo-v2.5-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl); expect(ex.buildUrl("mimo-v2.5-pro", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl); }); - it("authenticates Preview calls with the account cookie", () => { - const headers = ex.buildHeaders({ [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, true, "u", "mimo-x-pro-preview"); + it("routes v2.6 models to account route when desktop credentials are present", () => { + // No region → SGP default + const expected = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions"; + const credsWithToken = { providerSpecificData: { mimoPassToken: "token123" } }; + const credsWithCookie = { [COOKIE_KEY]: "serviceToken=abc" }; + + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, credsWithCookie)).toBe(expected); + expect(ex.buildUrl("xiaomi/mimo-v2.6-flash", true, 0, credsWithToken)).toBe(expected); + }); + + it("routes v2.6 models to cloud API when no desktop credentials are present", () => { + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, OPENAI_T)).toBe(OPENAI_T.runtimeTransport.baseUrl); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, CLAUDE_T)).toBe(CLAUDE_T.runtimeTransport.baseUrl); + }); + + it("resolves the account-service cluster per connection region", () => { + const cn = "https://mimo-server-cn.xiaomimimo.com/api/route/chat/completions"; + const sgp = "https://mimo-server-sgp.xiaomimimo.com/api/route/chat/completions"; + const ams = "https://mimo-server-ams.xiaomimimo.com/api/route/chat/completions"; + const ru = "https://mimo-server-ru.xiaomimimo.com/api/route/chat/completions"; + const inRegion = "https://mimo-server-in.xiaomimimo.com/api/route/chat/completions"; + // default (no region) falls back to SGP (the international cluster) + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { mimoPassToken: "t" } })).toBe(sgp); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "cn", mimoPassToken: "t" } })).toBe(cn); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "sgp", mimoPassToken: "t" } })).toBe(sgp); + expect(ex.buildUrl("mimo-v2.6-flash", true, 0, { providerSpecificData: { region: "SGP", mimoPassToken: "t" } })).toBe(sgp); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ams", mimoPassToken: "t" } })).toBe(ams); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "ru", mimoPassToken: "t" } })).toBe(ru); + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "in", mimoPassToken: "t" } })).toBe(inRegion); + // unknown region falls back to SGP + expect(ex.buildUrl("mimo-v2.6-pro", true, 0, { providerSpecificData: { region: "eu", mimoPassToken: "t" } })).toBe(sgp); + }); + + it("authenticates v2.6 calls with account cookie when on account route", () => { + const headers = ex.buildHeaders( + { [COOKIE_KEY]: "serviceToken=abc", accessToken: "sk-x" }, + true, + "u", + "mimo-v2.6-flash", + ); expect(headers.Cookie).toBe("serviceToken=abc"); expect(headers.Authorization).toBeUndefined(); }); @@ -43,38 +74,55 @@ describe("xiaomi-mimo executor", () => { expect(headers.Cookie).toBeUndefined(); }); - it("fails fast when a Preview call has no account session", async () => { - await expect( - ex.execute({ model: "mimo-x-pro-preview", body: {}, stream: true, credentials: {}, log: null }), - ).rejects.toThrow(/account session unavailable/); - }); - - it("flattens content-part arrays to plain strings", () => { + it("preserves content-part arrays for multimodal inputs", () => { + const parts = [{ type: "image_url", image_url: { url: "data:image/png;base64,xyz" } }, { type: "text", text: "hi" }]; const out = ex.transformRequest( - "mimo-x-pro-preview", - { messages: [{ role: "user", content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] }] }, + "mimo-v2.6-pro", + { messages: [{ role: "user", content: parts }] }, true, - {}, + { providerSpecificData: { mimoPassToken: "token" } }, ); - expect(out.messages[0].content).toBe("ab"); + expect(out.messages[0].content).toEqual(parts); }); - it("applies Preview defaults without overriding explicit values", () => { + it("bridges reasoning_effort to official output_config.effort", () => { + const creds = { providerSpecificData: { mimoPassToken: "token" } }; + const body = { + messages: [{ role: "user", content: "solve" }], + reasoning_effort: "high", + }; + const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds); + expect(out.reasoning_effort).toBeUndefined(); + expect(out.output_config).toEqual({ effort: "high" }); + }); + + it("normalizes xhigh reasoning_effort to high in output_config.effort", () => { + const creds = { providerSpecificData: { mimoPassToken: "token" } }; + const body = { + messages: [{ role: "user", content: "complex" }], + reasoning_effort: "xhigh", + }; + const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds); + expect(out.reasoning_effort).toBeUndefined(); + expect(out.output_config).toEqual({ effort: "high" }); + }); + + it("applies defaults without overriding explicit values", () => { + const creds = { providerSpecificData: { mimoPassToken: "token" } }; const body = { messages: [{ role: "user", content: "hi" }], temperature: 0.2 }; - const out = ex.transformRequest("mimo-x-pro-preview", body, true, {}); - expect(out.temperature).toBe(0.2); // caller's value kept - expect(out.top_p).toBe(0.95); // default filled in - expect(out.max_tokens).toBe(4096); + const out = ex.transformRequest("mimo-v2.6-pro", body, true, creds); + expect(out.temperature).toBe(0.2); + expect(out.top_p).toBe(0.95); }); - it("leaves cloud bodies free of Preview defaults", () => { + it("leaves cloud bodies free of account defaults", () => { const out = ex.transformRequest("mimo-v2.5-pro", { messages: [{ role: "user", content: "hi" }] }, true, {}); - expect(out.thinking).toBeUndefined(); - expect(out.max_tokens).toBeUndefined(); + expect(out.output_config).toBeUndefined(); + expect(out.temperature).toBeUndefined(); }); - it("strips a provider/model prefix when testing preview ids", () => { - expect(bareModel("xiaomi/mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); - expect(bareModel("mimo-x-pro-preview")).toBe("mimo-x-pro-preview"); + it("strips a provider/model prefix when testing model ids", () => { + expect(bareModel("xiaomi/mimo-v2.6-pro")).toBe("mimo-v2.6-pro"); + expect(bareModel("mimo-v2.6-flash")).toBe("mimo-v2.6-flash"); }); }); diff --git a/tests/unit/xiaomi-mimo-login-session-security.test.js b/tests/unit/xiaomi-mimo-login-session-security.test.js new file mode 100644 index 00000000..b9ec79a7 --- /dev/null +++ b/tests/unit/xiaomi-mimo-login-session-security.test.js @@ -0,0 +1,40 @@ +/** + * Security invariants of the server-assisted MiMo login proxy + * (src/lib/mimoLoginSession.js): + * - credentials bound to 9router's own origin are never forwarded upstream + * - upstream Set-Cookie is never replayed onto the app's own cookie jar + */ +import { describe, it, expect } from "vitest"; +import { __test__ } from "../../src/lib/mimoLoginSession.js"; + +const { STRIP_UPSTREAM_HEADERS, buildBrowserResponse } = __test__; + +describe("mimo login proxy security", () => { + it("strips auth credentials and session cookies before forwarding upstream", () => { + for (const h of ["authorization", "proxy-authorization", "cookie", "host"]) { + expect(STRIP_UPSTREAM_HEADERS.has(h)).toBe(true); + } + }); + + it("does not replay upstream Set-Cookie onto the app origin", async () => { + const upstream = new Response("ok", { + status: 200, + headers: { + "content-type": "text/html", + "set-cookie": "userId=123; Path=/", // plain object header: visible via getSetCookie + }, + }); + const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/pass/"); + expect(out.headers.getSetCookie()).toEqual([]); + }); + + it("keeps ordinary response headers intact", async () => { + const upstream = new Response("", { + status: 200, + headers: { "content-type": "text/html" }, + }); + const out = await buildBrowserResponse({ jar: new Map() }, upstream, "http://localhost:20128", "/fe/"); + expect(out.status).toBe(200); + expect(out.headers.get("content-type")).toBe("text/html"); + }); +});