Merge remote-tracking branch 'origin/master' into gitea/new_feature
# Conflicts: # open-sse/executors/qoder.js # open-sse/handlers/chatCore.js # open-sse/handlers/chatCore/sseToJsonHandler.js # open-sse/providers/registry/commandcode.js # src/app/(dashboard)/dashboard/combos/page.js # src/app/api/v1/models/route.js # src/lib/db/repos/usageRepo.js # src/shared/components/UsageStats.js
BIN
.github/issue-assets/combo-defaults/cursor-model-list.png
vendored
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
.github/issue-assets/combo-defaults/override-openai-base-url.png
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
429
.github/workflows/docker-publish.yml
vendored
@@ -5,22 +5,164 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
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:
|
env:
|
||||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
|
||||||
DOCKERHUB_IMAGE: decolua/9router
|
DOCKERHUB_IMAGE: decolua/9router
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
prepare:
|
||||||
|
name: Validate release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.release.outputs.tag }}
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
commit: ${{ steps.release.outputs.commit }}
|
||||||
|
publish_dockerhub: ${{ steps.release.outputs.publish_dockerhub }}
|
||||||
|
promote_latest: ${{ steps.release.outputs.promote_latest }}
|
||||||
|
ghcr_image: ${{ steps.release.outputs.ghcr_image }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out release tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.release_tag || github.ref_name }}
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Validate tag and package versions
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PROMOTE_LATEST_INPUT: ${{ inputs.promote_latest && 'true' || 'false' }}
|
||||||
|
run: |
|
||||||
|
node <<'NODE'
|
||||||
|
const fs = require("fs");
|
||||||
|
const { execFileSync } = require("child_process");
|
||||||
|
|
||||||
|
const tag = process.env.RELEASE_TAG || "";
|
||||||
|
const match = /^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$/.exec(tag);
|
||||||
|
|
||||||
|
if (tag.includes("+")) {
|
||||||
|
console.error(`Build metadata is not supported in Docker release tags: ${tag}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
console.error(`Expected a Docker-safe semver tag like v0.5.81 or v0.5.81-rc.1, received: ${tag || "<empty>"}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = match[1];
|
||||||
|
if (version.length > 128 || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(version)) {
|
||||||
|
console.error(`Version is not a valid Docker tag: ${version}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prerelease = version.includes("-")
|
||||||
|
? version.slice(version.indexOf("-") + 1).split(".")
|
||||||
|
: [];
|
||||||
|
for (const identifier of prerelease) {
|
||||||
|
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) {
|
||||||
|
console.error(`Numeric prerelease identifiers cannot contain leading zeroes: ${identifier}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootVersion = require("./package.json").version;
|
||||||
|
const cliVersion = require("./cli/package.json").version;
|
||||||
|
|
||||||
|
if (rootVersion !== version) {
|
||||||
|
console.error(`package.json version ${rootVersion} does not match tag ${tag}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliVersion !== version) {
|
||||||
|
console.error(`cli/package.json version ${cliVersion} does not match tag ${tag}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
||||||
|
const publishDockerHub = process.env.REPOSITORY === "decolua/9router";
|
||||||
|
const ghcrImage = `ghcr.io/${process.env.REPOSITORY.toLowerCase()}`;
|
||||||
|
const isPrerelease = version.includes("-");
|
||||||
|
const promoteLatest = (process.env.EVENT_NAME === "push" && !isPrerelease)
|
||||||
|
|| process.env.PROMOTE_LATEST_INPUT === "true";
|
||||||
|
const output = process.env.GITHUB_OUTPUT;
|
||||||
|
|
||||||
|
fs.appendFileSync(output, `tag=${tag}\n`);
|
||||||
|
fs.appendFileSync(output, `version=${version}\n`);
|
||||||
|
fs.appendFileSync(output, `commit=${commit}\n`);
|
||||||
|
fs.appendFileSync(output, `publish_dockerhub=${publishDockerHub}\n`);
|
||||||
|
fs.appendFileSync(output, `promote_latest=${promoteLatest}\n`);
|
||||||
|
fs.appendFileSync(output, `ghcr_image=${ghcrImage}\n`);
|
||||||
|
|
||||||
|
console.log(`Validated ${tag} at ${commit}`);
|
||||||
|
console.log(`latest promotion: ${promoteLatest ? "enabled" : "disabled"}`);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
GHCR_IMAGE: ${{ needs.prepare.outputs.ghcr_image }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
suffix: amd64
|
||||||
|
runner: ubuntu-24.04
|
||||||
|
- platform: linux/arm64
|
||||||
|
suffix: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
steps:
|
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
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -29,32 +171,267 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Log in to Docker Hub
|
||||||
|
if: needs.prepare.outputs.publish_dockerhub == 'true'
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Publish version image to Docker Hub
|
||||||
id: meta
|
if: needs.prepare.outputs.publish_dockerhub == 'true'
|
||||||
uses: docker/metadata-action@v5
|
env:
|
||||||
with:
|
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
|
||||||
images: |
|
GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
|
||||||
${{ env.GHCR_IMAGE }}
|
VERSION: ${{ needs.prepare.outputs.version }}
|
||||||
${{ env.DOCKERHUB_IMAGE }}
|
run: |
|
||||||
tags: |
|
set -euo pipefail
|
||||||
type=semver,pattern={{version}}
|
docker buildx imagetools create \
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
--tag "${DOCKERHUB_IMAGE}:${VERSION}" \
|
||||||
|
"${GHCR_IMAGE}:${VERSION}"
|
||||||
|
|
||||||
- name: Build and push
|
docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}" | tee "$RUNNER_TEMP/dockerhub-version-manifest.txt"
|
||||||
uses: docker/build-push-action@v6
|
docker buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}:${VERSION}" > "$RUNNER_TEMP/dockerhub-version-manifest.json"
|
||||||
with:
|
|
||||||
context: .
|
expected=$'linux/amd64\nlinux/arm64'
|
||||||
push: true
|
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")"
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
if [[ "$actual" != "$expected" ]]; then
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
echo "Docker Hub version manifest platforms do not match exactly:" >&2
|
||||||
cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache
|
printf '%s\n' "$actual" >&2
|
||||||
cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache,mode=max
|
exit 1
|
||||||
platforms: linux/amd64,linux/arm64
|
fi
|
||||||
provenance: false
|
|
||||||
sbom: false
|
- 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"
|
||||||
|
|||||||
33
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)
|
# v0.5.81 (2026-09-18)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -7,6 +38,8 @@
|
|||||||
- **i18n**: integrate Persian (fa) translation
|
- **i18n**: integrate Persian (fa) translation
|
||||||
|
|
||||||
## Fixes
|
## 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
|
- **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
|
- **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
|
- **Stream**: report aborts after HTTP 200 in-band (per-format error frames) instead of closing silently
|
||||||
|
|||||||
67
DOCKER.md
@@ -100,6 +100,12 @@ docker rm -f 9router
|
|||||||
# re-run the quick start command
|
# 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
|
# 🛠 For Developers
|
||||||
@@ -107,7 +113,7 @@ docker rm -f 9router
|
|||||||
## Build image locally (test)
|
## Build image locally (test)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app && docker build -t 9router .
|
docker build -t 9router .
|
||||||
|
|
||||||
docker run --rm -p 20128:20128 \
|
docker run --rm -p 20128:20128 \
|
||||||
-v "$HOME/.9router:/app/data" \
|
-v "$HOME/.9router:/app/data" \
|
||||||
@@ -115,18 +121,67 @@ docker run --rm -p 20128:20128 \
|
|||||||
9router
|
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)
|
## Publish (automatic via CI)
|
||||||
|
|
||||||
Push a git tag `v*` → GitHub Actions builds multi-platform (amd64+arm64) and pushes to:
|
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:v{version}` + `:latest`
|
|
||||||
- `decolua/9router:v{version}` + `:latest`
|
- `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
|
```bash
|
||||||
# Use scripts/release.js (recommended)
|
# Use scripts/release.js (recommended)
|
||||||
node scripts/release.js "Release title" "Notes"
|
node scripts/release.js "Release title" "Notes"
|
||||||
|
|
||||||
# Or manually
|
# Or manually
|
||||||
git tag v0.4.x && git push origin v0.4.x
|
git tag v0.5.81 && git push origin v0.5.81
|
||||||
```
|
```
|
||||||
|
|
||||||
Workflow: `app/.github/workflows/docker-publish.yml`
|
To republish an existing tag, run the `Build and Push Docker Image` workflow manually and provide the exact tag, for example `v0.5.81`, in the `release_tag` input. Manual runs publish the numbered tag but leave `latest` unchanged by default:
|
||||||
|
|
||||||
|
```text
|
||||||
|
release_tag: v0.5.81
|
||||||
|
promote_latest: false
|
||||||
|
```
|
||||||
|
|
||||||
|
The `promote_latest` checkbox is an explicit opt-in for changing `latest`. Use it when a deliberate rollback or recovery should make that version the current default:
|
||||||
|
|
||||||
|
```text
|
||||||
|
release_tag: v0.5.75
|
||||||
|
promote_latest: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Numbered image tags are mutable because a republish can replace their manifest. For a deployment that must be immutable, pin the image digest instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull decolua/9router@sha256:<verified-digest>
|
||||||
|
```
|
||||||
|
|
||||||
|
The release workflow runs `/api/health` on each native `amd64` and `arm64` platform image before it uploads the digest artifact or assembles the multi-platform manifest. It then runs a second health check against the resolved version manifest before any requested `latest` promotion.
|
||||||
|
|
||||||
|
During recovery, the selected tag remains the application source while the Dockerfile from the workflow revision is used, so an older tag can be rebuilt with the current publishing fixes.
|
||||||
|
|
||||||
|
The workflow is tag-driven. Creating a git tag does not automatically create a GitHub Release, so the Releases page and the published package/image tags can be at different versions unless a maintainer creates a release separately.
|
||||||
|
|
||||||
|
The upstream repository needs these repository secrets for Docker Hub publishing:
|
||||||
|
|
||||||
|
- `DOCKERHUB_USERNAME`
|
||||||
|
- `DOCKERHUB_TOKEN`
|
||||||
|
|
||||||
|
GHCR publishing uses the workflow's `GITHUB_TOKEN` with package write permission. Forks can publish to their own GHCR namespace, but Docker Hub publication is restricted to the upstream `decolua/9router` repository.
|
||||||
|
|
||||||
|
The optional repository variables `ALPINE_MIRROR` and `NPM_REGISTRY` can override the default package mirrors used by the CI Docker build.
|
||||||
|
|
||||||
|
Workflow: `.github/workflows/docker-publish.yml`
|
||||||
|
|||||||
39
Dockerfile
@@ -1,16 +1,33 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
ARG NODE_IMAGE=node:22-alpine
|
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
|
FROM ${NODE_IMAGE} AS base
|
||||||
|
ARG ALPINE_MIRROR
|
||||||
WORKDIR /app
|
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
|
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 ./
|
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 . ./
|
COPY . ./
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
@@ -19,9 +36,16 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM ${NODE_IMAGE} AS runner
|
FROM ${NODE_IMAGE} AS runner
|
||||||
|
ARG ALPINE_MIRROR
|
||||||
|
ARG APP_VERSION
|
||||||
WORKDIR /app
|
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 NODE_ENV=production
|
||||||
ENV PORT=20128
|
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 && \
|
mkdir -p /app/data-home && chown node:node /app/data-home && \
|
||||||
ln -sf /app/data-home /root/.9router 2>/dev/null || true
|
ln -sf /app/data-home /root/.9router 2>/dev/null || true
|
||||||
|
|
||||||
# Fix permissions at runtime (handles mounted volumes)
|
# Avoid a full distribution upgrade in the runtime image. It makes builds less
|
||||||
RUN apk --no-cache upgrade && apk --no-cache add su-exec && \
|
# 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 && \
|
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
|
chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
|||||||
24
README.md
@@ -110,7 +110,22 @@ PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
|||||||
Production mode:
|
Production mode:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -215,7 +230,14 @@ Default URLs:
|
|||||||
<b>🇻🇳 Tiếng Việt</b><br/>
|
<b>🇻🇳 Tiếng Việt</b><br/>
|
||||||
<sub>Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z<br/>by <a href="https://github.com/tuanminhhole">tuanminhhole</a></sub>
|
<sub>Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z<br/>by <a href="https://github.com/tuanminhhole">tuanminhhole</a></sub>
|
||||||
</td>
|
</td>
|
||||||
<td align="center" width="320"></td>
|
<td align="center" width="320">
|
||||||
|
<a href="https://www.youtube.com/watch?v=hgnE7MKi3Y4">
|
||||||
|
<img src="https://img.youtube.com/vi/hgnE7MKi3Y4/maxresdefault.jpg" alt="Bye Limit! Cara Bikin Sistem 'AI Unlimited' 100% Gratis Dengan 9Router!
|
||||||
|
" width="300"/>
|
||||||
|
</a><br/>
|
||||||
|
<b>🇮🇩 Indonesia</b><br/>
|
||||||
|
<sub>Bye Limit! Cara Bikin Sistem "AI Unlimited" 100% Gratis Dengan 9Router!<br/>by <a href="https://www.youtube.com/@neptiver">neptiver</a></sub>
|
||||||
|
</td>
|
||||||
<td align="center" width="320"></td>
|
<td align="center" width="320"></td>
|
||||||
<td align="center" width="320"></td>
|
<td align="center" width="320"></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ Any tool supporting OpenAI/Claude-compatible API works.
|
|||||||
Full docs, advanced setup, video tutorials & development guide:
|
Full docs, advanced setup, video tutorials & development guide:
|
||||||
|
|
||||||
- **GitHub**: https://github.com/decolua/9router
|
- **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
|
- **Website**: https://9router.com
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router",
|
"name": "9router",
|
||||||
"version": "0.5.81",
|
"version": "0.5.86",
|
||||||
"description": "9Router CLI - Start and manage 9Router server",
|
"description": "9Router CLI - Start and manage 9Router server",
|
||||||
"bin": {
|
"bin": {
|
||||||
"9router": "./cli.js"
|
"9router": "./cli.js"
|
||||||
|
|||||||
@@ -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 Combos
|
||||||
|
|
||||||
### Example 1: Premium Coding (Subscription → Cheap → Free)
|
### Example 1: Premium Coding (Subscription → Cheap → Free)
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ const nextConfig = {
|
|||||||
source: "/responses",
|
source: "/responses",
|
||||||
destination: "/api/v1/responses"
|
destination: "/api/v1/responses"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/systemone",
|
||||||
|
destination: "/api/v1/systemone"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
source: "/v1beta/:path*",
|
source: "/v1beta/:path*",
|
||||||
destination: "/api/v1beta/:path*"
|
destination: "/api/v1beta/:path*"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM cha
|
|||||||
|
|
||||||
## Request lifecycle (chat)
|
## 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
|
## Directory map
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function findModelName(aliasOrId, modelId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getModelTargetFormat(aliasOrId, modelId) {
|
export function getModelTargetFormat(aliasOrId, modelId) {
|
||||||
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go") && isMuseSparkModel(modelId)) {
|
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode" || aliasOrId === "ocg" || aliasOrId === "opencode-go" || aliasOrId === "ocz" || aliasOrId === "opencode-zen") && isMuseSparkModel(modelId)) {
|
||||||
return FORMATS.OPENAI_RESPONSES;
|
return FORMATS.OPENAI_RESPONSES;
|
||||||
}
|
}
|
||||||
const models = PROVIDER_MODELS[aliasOrId];
|
const models = PROVIDER_MODELS[aliasOrId];
|
||||||
|
|||||||
@@ -293,12 +293,19 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
this._lastSessionId = transformedRequest.sessionId; // cached for buildHeaders (base.execute order)
|
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 {
|
return {
|
||||||
...body,
|
...body,
|
||||||
project: projectId,
|
project: projectId,
|
||||||
model: body.model || model,
|
model: body.model || model,
|
||||||
userAgent: "antigravity",
|
userAgent: "antigravity",
|
||||||
requestType: "agent",
|
|
||||||
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
|
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
|
||||||
request: transformedRequest
|
request: transformedRequest
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,13 +7,16 @@ import {
|
|||||||
wrapConnectRPCFrame,
|
wrapConnectRPCFrame,
|
||||||
decodeMessage,
|
decodeMessage,
|
||||||
parseConnectRPCFrame,
|
parseConnectRPCFrame,
|
||||||
extractTextFromResponse
|
extractTextFromResponse,
|
||||||
|
encodeMcpTools,
|
||||||
|
decodeMcpArgs,
|
||||||
} from "../utils/cursorProtobuf.js";
|
} from "../utils/cursorProtobuf.js";
|
||||||
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
||||||
import { estimateUsage } from "../utils/usageTracking.js";
|
import { estimateUsage } from "../utils/usageTracking.js";
|
||||||
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
||||||
import { chatChunkSse, sseChunk } from "../utils/sse.js";
|
import { chatChunkSse, sseChunk } from "../utils/sse.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
import { ROLE, OPENAI_BLOCK } from "../translator/schema/index.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@@ -65,55 +68,74 @@ function textFromContent(content) {
|
|||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") return content;
|
||||||
if (!Array.isArray(content)) return "";
|
if (!Array.isArray(content)) return "";
|
||||||
return content
|
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)
|
.map((part) => part.text)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAgentTextRequest(body) {
|
function isTextPart(part) {
|
||||||
// Many compatible clients always attach their built-in tool schemas, even
|
return !part || part.type === OPENAI_BLOCK.TEXT || typeof part === "string";
|
||||||
// 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
|
export function isAgentCapableRequest(body) {
|
||||||
// until its AgentService tool protocol is implemented.
|
// ChatService rejects auto/composer and most thinking variants. AgentService
|
||||||
return Array.isArray(body?.messages) && body.messages.every((message) => {
|
// can answer text turns (including declared tool schemas) and tool-call
|
||||||
if (message?.tool_calls?.length || message?.role === "tool") return false;
|
// history. Image parts still need the legacy protobuf path.
|
||||||
return typeof message?.content === "string"
|
if (!Array.isArray(body?.messages) || body.messages.length === 0) return false;
|
||||||
|| Array.isArray(message?.content) && message.content.every((part) => part?.type === "text");
|
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) {
|
function encodeHistoryMessage(message) {
|
||||||
const content = textFromContent(message?.content);
|
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.
|
// ConversationHistoryMessage.user / .assistant -> repeated content -> text.
|
||||||
const text = agentString(1, content);
|
const text = agentString(1, textBody);
|
||||||
if (message.role === "assistant") {
|
if (message.role === ROLE.ASSISTANT) {
|
||||||
return agentMessage(2, agentMessage(1, agentMessage(1, text)));
|
return agentMessage(2, agentMessage(1, agentMessage(1, text)));
|
||||||
}
|
}
|
||||||
return agentMessage(1, 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
|
const system = messages
|
||||||
.filter((message) => message?.role === "system")
|
.filter((message) => message?.role === ROLE.SYSTEM)
|
||||||
.map((message) => textFromContent(message.content))
|
.map((message) => textFromContent(message.content))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("\n\n");
|
.join("\n\n");
|
||||||
const chatMessages = messages.filter((message) => message?.role !== "system");
|
const chatMessages = messages.filter((message) => message?.role !== ROLE.SYSTEM);
|
||||||
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user");
|
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf(ROLE.USER);
|
||||||
const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1);
|
const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1);
|
||||||
const history = chatMessages
|
const history = chatMessages
|
||||||
.slice(0, currentIndex >= 0 ? currentIndex : -1)
|
.slice(0, currentIndex >= 0 ? currentIndex : -1)
|
||||||
.map(encodeHistoryMessage)
|
.map(encodeHistoryMessage)
|
||||||
.filter(Boolean);
|
.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.
|
// 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(
|
const userMessage = concatBuffers(
|
||||||
agentString(1, userText),
|
agentString(1, userText),
|
||||||
agentString(2, crypto.randomUUID()),
|
agentString(2, crypto.randomUUID()),
|
||||||
|
agentMessage(3, new Uint8Array()),
|
||||||
|
encodeField(4, PROTOBUF_VARINT, 1),
|
||||||
);
|
);
|
||||||
const conversationHistory = history.length
|
const conversationHistory = history.length
|
||||||
? concatBuffers(...history.map((entry) => agentMessage(1, entry)))
|
? concatBuffers(...history.map((entry) => agentMessage(1, entry)))
|
||||||
@@ -124,11 +146,20 @@ function buildAgentRunFrame(messages, model) {
|
|||||||
);
|
);
|
||||||
const conversationAction = agentMessage(1, userAction);
|
const conversationAction = agentMessage(1, userAction);
|
||||||
const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true));
|
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(
|
const runRequest = concatBuffers(
|
||||||
// An empty ConversationStateStructure starts a fresh local agent session.
|
// An empty ConversationStateStructure starts a fresh local agent session.
|
||||||
agentMessage(1, new Uint8Array()),
|
agentMessage(1, new Uint8Array()),
|
||||||
agentMessage(2, conversationAction),
|
agentMessage(2, conversationAction),
|
||||||
...(system ? [agentString(8, system)] : []),
|
agentMessage(3, modelDetails),
|
||||||
|
...(mcpTools.length ? [agentMessage(4, mcpTools)] : []),
|
||||||
agentMessage(9, requestedModel),
|
agentMessage(9, requestedModel),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,13 +188,51 @@ function decodeAgentFrames(buffer, onFrame) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRequestContextResponse() {
|
function execIds(execRequest) {
|
||||||
// AgentService asks every run for client context. 9router has no IDE file
|
const id = Number(execRequest?.get(1)?.[0]?.value || 0);
|
||||||
// context, so acknowledge with an empty RequestContext.
|
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 requestContextSuccess = agentMessage(1, new Uint8Array());
|
||||||
const requestContextResult = agentMessage(1, requestContextSuccess);
|
const requestContextResult = agentMessage(1, requestContextSuccess);
|
||||||
const execClientMessage = agentMessage(10, requestContextResult);
|
return wrapExecClientMessage(id, execId, 10, requestContextResult);
|
||||||
return wrapConnectRPCFrame(agentMessage(2, execClientMessage));
|
}
|
||||||
|
|
||||||
|
// 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";
|
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;
|
const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint;
|
||||||
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
|
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
|
||||||
|
|
||||||
@@ -491,9 +560,10 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let session;
|
let session;
|
||||||
|
const tools = body.tools || [];
|
||||||
try {
|
try {
|
||||||
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
|
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
|
||||||
session.write(buildAgentRunFrame(body.messages || [], model));
|
session.write(buildAgentRunFrame(body.messages || [], model, tools));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Cursor AgentService request failed: ${error.message}`);
|
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.
|
// so strict clients such as Claude Code accept the completed stream.
|
||||||
const responseId = `chatcmpl-msg_${Date.now()}`;
|
const responseId = `chatcmpl-msg_${Date.now()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
const composerModel = isComposerModel(model);
|
||||||
let pending = Buffer.alloc(0);
|
let pending = Buffer.alloc(0);
|
||||||
let finished = false;
|
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) => {
|
const consume = async (onEvent) => {
|
||||||
try {
|
try {
|
||||||
@@ -553,32 +638,87 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
const update = decodeMessage(serverMessage.get(1)[0].value);
|
const update = decodeMessage(serverMessage.get(1)[0].value);
|
||||||
if (update.has(1)) {
|
if (update.has(1)) {
|
||||||
const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 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
|
// thinking_delta (field 4). Composer (and some Grok variants) put
|
||||||
// cryptographic signature required by Anthropic thinking blocks.
|
// the visible answer after </think> here and never send text_delta.
|
||||||
// Forwarding it makes strict Anthropic clients (Claude Code)
|
if (update.has(4)) {
|
||||||
// discard or wait on an otherwise complete response. Keep the
|
const thinkingDelta = extractAgentString(decodeMessage(update.get(4)[0].value), 1);
|
||||||
// reasoning upstream-only and emit the normal answer text.
|
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)) {
|
if (update.has(14)) {
|
||||||
|
flushThinkingFallback(onEvent);
|
||||||
finished = true;
|
finished = true;
|
||||||
onEvent({ type: "done" });
|
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.
|
// AgentService requests IDE context before producing a response.
|
||||||
// Return an empty context; 9router is not coupled to an editor.
|
|
||||||
if (serverMessage.has(2)) {
|
if (serverMessage.has(2)) {
|
||||||
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
|
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
|
||||||
if (execRequest.has(10)) {
|
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 {
|
} else {
|
||||||
// Every other ExecServerMessage variant is an editor-backed tool
|
// Auto/Composer often probe IDE builtins (shell/read/…). Reject
|
||||||
// (shell, read, write, …) that 9router cannot service. Fail the
|
// them so the model can continue with MCP tools or a text answer
|
||||||
// turn rather than narrating protocol state as assistant text.
|
// instead of stalling the h2 stream.
|
||||||
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
|
const rejection = rejectExecRequest(execRequest);
|
||||||
finished = true;
|
if (rejection) {
|
||||||
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
|
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 {
|
} finally {
|
||||||
try { session.end(); } catch {}
|
try { session.end(); } catch {}
|
||||||
try { session.close(); } 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 content = "";
|
||||||
let reasoning = "";
|
let reasoning = "";
|
||||||
let agentError = null;
|
let agentError = null;
|
||||||
|
const toolCalls = [];
|
||||||
|
let finishReason = "stop";
|
||||||
await consume((event) => {
|
await consume((event) => {
|
||||||
if (event.type === "text") content += event.value;
|
if (event.type === "text") content += event.value;
|
||||||
else if (event.type === "thinking") reasoning += 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 === "error") agentError = event.value;
|
||||||
|
else if (event.type === "done" && event.finishReason) finishReason = event.finishReason;
|
||||||
});
|
});
|
||||||
if (agentError) {
|
if (agentError) {
|
||||||
return {
|
return {
|
||||||
@@ -611,13 +765,19 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
responseFormat: FORMATS.OPENAI,
|
responseFormat: FORMATS.OPENAI,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const message = {
|
||||||
|
role: "assistant",
|
||||||
|
content: content || null,
|
||||||
|
...(reasoning ? { reasoning_content: reasoning } : {}),
|
||||||
|
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
response: new Response(JSON.stringify({
|
response: new Response(JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
created,
|
created,
|
||||||
model,
|
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),
|
usage: estimateUsage(body, content.length, FORMATS.OPENAI),
|
||||||
}), { headers: { "Content-Type": "application/json" } }),
|
}), { headers: { "Content-Type": "application/json" } }),
|
||||||
url,
|
url,
|
||||||
@@ -635,6 +795,18 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
|
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
|
||||||
} else if (event.type === "thinking") {
|
} else if (event.type === "thinking") {
|
||||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
|
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") {
|
} else if (event.type === "error") {
|
||||||
// An SSE error frame, not a content delta: a protocol failure must not
|
// 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
|
// 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.enqueue(encoder.encode(SSE_DONE));
|
||||||
controller.close();
|
controller.close();
|
||||||
} else if (event.type === "done") {
|
} 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.enqueue(encoder.encode(SSE_DONE));
|
||||||
controller.close();
|
controller.close();
|
||||||
}
|
}
|
||||||
@@ -664,9 +839,9 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||||
if (isAgentTextRequest(body)) {
|
if (isAgentCapableRequest(body)) {
|
||||||
try {
|
try {
|
||||||
return await this.executeAgent({ model, body, stream, credentials, signal });
|
return await this.executeAgent({ model, body, stream, credentials, signal, log });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
response: new Response(JSON.stringify({
|
response: new Response(JSON.stringify({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CursorExecutor } from "./cursor.js";
|
|||||||
import { VertexExecutor } from "./vertex.js";
|
import { VertexExecutor } from "./vertex.js";
|
||||||
import { OpenCodeExecutor } from "./opencode.js";
|
import { OpenCodeExecutor } from "./opencode.js";
|
||||||
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||||
|
import { OpenCodeZenExecutor } from "./opencode-zen.js";
|
||||||
import { GrokWebExecutor } from "./grok-web.js";
|
import { GrokWebExecutor } from "./grok-web.js";
|
||||||
import { GrokCliExecutor } from "./grok-cli.js";
|
import { GrokCliExecutor } from "./grok-cli.js";
|
||||||
import { PerplexityWebExecutor } from "./perplexity-web.js";
|
import { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||||
@@ -34,6 +35,7 @@ const executors = {
|
|||||||
github: new GithubExecutor(),
|
github: new GithubExecutor(),
|
||||||
iflow: new IFlowExecutor(),
|
iflow: new IFlowExecutor(),
|
||||||
qoder: new QoderExecutor(),
|
qoder: new QoderExecutor(),
|
||||||
|
"qoder-cn": new QoderExecutor("qoder-cn"),
|
||||||
kiro: new KiroExecutor(),
|
kiro: new KiroExecutor(),
|
||||||
kimchi: new KimchiExecutor(),
|
kimchi: new KimchiExecutor(),
|
||||||
codex: new CodexExecutor(),
|
codex: new CodexExecutor(),
|
||||||
@@ -43,6 +45,7 @@ const executors = {
|
|||||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||||
opencode: new OpenCodeExecutor(),
|
opencode: new OpenCodeExecutor(),
|
||||||
"opencode-go": new OpenCodeGoExecutor(),
|
"opencode-go": new OpenCodeGoExecutor(),
|
||||||
|
"opencode-zen": new OpenCodeZenExecutor(),
|
||||||
"grok-web": new GrokWebExecutor(),
|
"grok-web": new GrokWebExecutor(),
|
||||||
"grok-cli": new GrokCliExecutor(),
|
"grok-cli": new GrokCliExecutor(),
|
||||||
gcli: new GrokCliExecutor(), // Alias
|
gcli: new GrokCliExecutor(), // Alias
|
||||||
@@ -89,6 +92,7 @@ export { VertexExecutor } from "./vertex.js";
|
|||||||
export { DefaultExecutor } from "./default.js";
|
export { DefaultExecutor } from "./default.js";
|
||||||
export { OpenCodeExecutor } from "./opencode.js";
|
export { OpenCodeExecutor } from "./opencode.js";
|
||||||
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||||
|
export { OpenCodeZenExecutor } from "./opencode-zen.js";
|
||||||
export { GrokWebExecutor } from "./grok-web.js";
|
export { GrokWebExecutor } from "./grok-web.js";
|
||||||
export { GrokCliExecutor } from "./grok-cli.js";
|
export { GrokCliExecutor } from "./grok-cli.js";
|
||||||
export { PerplexityWebExecutor } from "./perplexity-web.js";
|
export { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||||
|
|||||||
315
open-sse/executors/opencode-zen.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { DefaultExecutor } from "./default.js";
|
||||||
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||||
|
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
||||||
|
import {
|
||||||
|
normalizeResponsesInput,
|
||||||
|
clampResponsesCallId,
|
||||||
|
coerceResponsesArguments,
|
||||||
|
coerceResponsesOutput,
|
||||||
|
} from "../translator/formats/responsesApi.js";
|
||||||
|
|
||||||
|
const SESSION_HEADER = "x-opencode-session";
|
||||||
|
const SESSION_FIELD = "_opencodeZenSession";
|
||||||
|
const MAX_SESSION_LENGTH = 256;
|
||||||
|
|
||||||
|
const RESPONSES_BASE_URL = "https://opencode.ai/zen/v1/responses";
|
||||||
|
const MAX_TOOL_NAME_LEN = 128;
|
||||||
|
const OPENCODE_UA = "opencode/1.18.31";
|
||||||
|
export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
|
||||||
|
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
// Free-tier fingerprint (mirrors opencode executor, PR #4132): upstream 403s
|
||||||
|
// requests without the file-search quartet and without stream:true.
|
||||||
|
const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"];
|
||||||
|
|
||||||
|
function hasValidOpencodeVersion(ua) {
|
||||||
|
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
||||||
|
if (!m) return false;
|
||||||
|
const major = parseInt(m[1], 10);
|
||||||
|
const minor = parseInt(m[2], 10);
|
||||||
|
return major > 1 || (major === 1 && minor >= 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unstableRandom() {
|
||||||
|
const bytes = crypto.randomBytes(14);
|
||||||
|
let randomPart = "";
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
randomPart += BASE62_CHARS[bytes[i] % 62];
|
||||||
|
}
|
||||||
|
return randomPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSessionId(timestamp = Date.now()) {
|
||||||
|
const current = BigInt(timestamp) * 0x1000n + 1n;
|
||||||
|
const value = ~current;
|
||||||
|
const time = Array.from({ length: 6 }, (_, index) =>
|
||||||
|
Number((value >> BigInt(40 - 8 * index)) & 0xffn)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")
|
||||||
|
).join("");
|
||||||
|
return `ses_${time}${unstableRandom()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRequestId(timestamp = Date.now()) {
|
||||||
|
const current = BigInt(timestamp) * 0x1000n + 1n;
|
||||||
|
const value = current;
|
||||||
|
const time = Array.from({ length: 6 }, (_, index) =>
|
||||||
|
Number((value >> BigInt(40 - 8 * index)) & 0xffn)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0")
|
||||||
|
).join("");
|
||||||
|
return `msg_${time}${unstableRandom()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateSessionId(sessionId, clientTool = "") {
|
||||||
|
if (typeof sessionId === "string" && OPENCODE_SESSION_RE.test(sessionId.trim())) {
|
||||||
|
return sessionId.trim();
|
||||||
|
}
|
||||||
|
const digest = crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(`opencode\0${clientTool || "generic"}\0${sessionId || ""}`)
|
||||||
|
.digest();
|
||||||
|
const timeHex = digest.subarray(0, 6).toString("hex");
|
||||||
|
let randomPart = "";
|
||||||
|
for (let i = 6; i < 20; i++) {
|
||||||
|
randomPart += BASE62_CHARS[digest[i] % 62];
|
||||||
|
}
|
||||||
|
return `ses_${timeHex}${randomPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolNameOf(tool) {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
|
||||||
|
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null;
|
||||||
|
const raw = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : "");
|
||||||
|
return raw.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChatFingerprintTools(body) {
|
||||||
|
if (!body || typeof body !== "object") return;
|
||||||
|
const present = new Set();
|
||||||
|
if (Array.isArray(body.tools)) {
|
||||||
|
for (const tool of body.tools) {
|
||||||
|
const name = toolNameOf(tool);
|
||||||
|
if (name) present.add(name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.tools = [];
|
||||||
|
}
|
||||||
|
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
||||||
|
if (present.has(name)) continue;
|
||||||
|
body.tools.push({
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description: `OpenCode built-in ${name} tool`,
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
present.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureResponsesFingerprintTools(body) {
|
||||||
|
if (!body || typeof body !== "object") return;
|
||||||
|
const present = new Set();
|
||||||
|
if (Array.isArray(body.tools)) {
|
||||||
|
for (const tool of body.tools) {
|
||||||
|
const name = toolNameOf(tool);
|
||||||
|
if (name) present.add(name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
body.tools = [];
|
||||||
|
}
|
||||||
|
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
||||||
|
if (present.has(name)) continue;
|
||||||
|
body.tools.push({
|
||||||
|
type: "function",
|
||||||
|
name,
|
||||||
|
description: `OpenCode built-in ${name} tool`,
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
});
|
||||||
|
present.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSession(value) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized || normalized.length > MAX_SESSION_LENGTH) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSession(headers) {
|
||||||
|
if (!headers || typeof headers !== "object") return null;
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === SESSION_HEADER) {
|
||||||
|
const normalized = normalizeSession(value);
|
||||||
|
if (normalized && OPENCODE_SESSION_RE.test(normalized)) return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function translatedSession(sessionId, clientTool) {
|
||||||
|
return translateSessionId(sessionId, clientTool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the thinking suffix "model(level)" so checks hit the base id.
|
||||||
|
function baseModelId(model) {
|
||||||
|
return String(model || "").replace(/\([^()]+\)\s*$/, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResponsesModel(model) {
|
||||||
|
return isMuseSparkModel(baseModelId(model));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten Chat Completions tool declarations into the Responses flat shape and
|
||||||
|
// drop hosted/nameless tools the /responses endpoint rejects.
|
||||||
|
function normalizeResponsesTools(body) {
|
||||||
|
if (!Array.isArray(body.tools)) return;
|
||||||
|
const validNames = new Set();
|
||||||
|
body.tools = body.tools.filter((tool) => {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
|
||||||
|
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null;
|
||||||
|
const rawName = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : "");
|
||||||
|
const name = rawName.trim();
|
||||||
|
if (!name) return false;
|
||||||
|
const description = typeof tool.description === "string" ? tool.description : (typeof fn?.description === "string" ? fn.description : "");
|
||||||
|
let parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters))
|
||||||
|
? tool.parameters
|
||||||
|
: (fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} });
|
||||||
|
// Mirror the request translator: {type:"object"} without properties is rejected
|
||||||
|
// by strict Responses backends, so fill in the empty properties map.
|
||||||
|
if (parameters.type === "object" && !parameters.properties) parameters = { ...parameters, properties: {} };
|
||||||
|
for (const k of Object.keys(tool)) delete tool[k];
|
||||||
|
tool.type = "function";
|
||||||
|
tool.name = name.slice(0, MAX_TOOL_NAME_LEN);
|
||||||
|
if (description) tool.description = description;
|
||||||
|
tool.parameters = parameters;
|
||||||
|
validNames.add(tool.name);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
|
||||||
|
if (body.tool_choice.type === "function") {
|
||||||
|
const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : "";
|
||||||
|
if (!n || !validNames.has(n)) delete body.tool_choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last line of defense for native Responses clients (sourceFormat === targetFormat
|
||||||
|
// skips translation): coerce items in place so malformed tool payloads 400 here
|
||||||
|
// with a clear shape instead of upstream as InputValidationError.
|
||||||
|
function sanitizeResponsesItems(body) {
|
||||||
|
if (!Array.isArray(body.input)) return;
|
||||||
|
body.input = body.input.filter((item) => {
|
||||||
|
if (!item || typeof item !== "object" || Array.isArray(item)) return true;
|
||||||
|
// Strip prior-turn reasoning items: Muse Spark contributor models route to
|
||||||
|
// an upstream Console backend where encrypted_content cannot be validated across
|
||||||
|
// rotated accounts or sessions, causing 400 "reasoning encrypted_content was not issued to this caller".
|
||||||
|
if (item.type === "reasoning") return false;
|
||||||
|
delete item.encrypted_content;
|
||||||
|
delete item.reasoning_encrypted_content;
|
||||||
|
if (item.type === "function_call") {
|
||||||
|
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") return false;
|
||||||
|
item.name = item.name.trim().slice(0, MAX_TOOL_NAME_LEN);
|
||||||
|
item.call_id = clampResponsesCallId(item.call_id);
|
||||||
|
item.arguments = coerceResponsesArguments(item.arguments);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (item.type === "function_call_output") {
|
||||||
|
item.call_id = clampResponsesCallId(item.call_id);
|
||||||
|
item.output = coerceResponsesOutput(item.output);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenCodeZenExecutor extends DefaultExecutor {
|
||||||
|
constructor() {
|
||||||
|
super("opencode-zen");
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||||
|
// Muse Spark lives on /responses even when a stale runtimeTransport leaks in.
|
||||||
|
if (isResponsesModel(model)) return RESPONSES_BASE_URL;
|
||||||
|
return super.buildUrl(model, stream, urlIndex, credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareRequestCredentials({ body, credentials, providerSessionId, clientTool } = {}) {
|
||||||
|
const sourceCredentials = credentials || {};
|
||||||
|
const native = nativeSession(sourceCredentials.rawHeaders);
|
||||||
|
const resolved = normalizeSession(providerSessionId) || resolveSessionId({
|
||||||
|
headers: sourceCredentials.rawHeaders,
|
||||||
|
body,
|
||||||
|
connectionId: sourceCredentials.connectionId,
|
||||||
|
scope: "opencode-zen",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sourceCredentials,
|
||||||
|
[SESSION_FIELD]: native || translatedSession(resolved, clientTool),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(args) {
|
||||||
|
const credentials = this.prepareRequestCredentials(args);
|
||||||
|
return super.execute({ ...args, credentials });
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHeaders(credentials, stream = true, url, model) {
|
||||||
|
const headers = super.buildHeaders(credentials || {}, stream, url, model);
|
||||||
|
const raw = credentials?.rawHeaders || {};
|
||||||
|
const lower = {};
|
||||||
|
for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v;
|
||||||
|
const downstreamUa = lower["user-agent"] || "";
|
||||||
|
// Free-tier gate: spoof the official client UA.
|
||||||
|
headers["User-Agent"] = hasValidOpencodeVersion(downstreamUa) ? downstreamUa : OPENCODE_UA;
|
||||||
|
headers["x-opencode-client"] = lower["x-opencode-client"] || "desktop";
|
||||||
|
const prepared = credentials?.[SESSION_FIELD];
|
||||||
|
if (prepared) {
|
||||||
|
headers[SESSION_HEADER] = prepared;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = this.prepareRequestCredentials({ credentials });
|
||||||
|
headers[SESSION_HEADER] = fallback[SESSION_FIELD];
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformRequest(model, body, stream, credentials) {
|
||||||
|
const out = super.transformRequest(model, body);
|
||||||
|
// Free-tier gate: upstream 403s stream:false even when everything else is valid.
|
||||||
|
if (out && typeof out === "object") out.stream = true;
|
||||||
|
if (!isResponsesModel(model || body?.model)) {
|
||||||
|
ensureChatFingerprintTools(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const normalized = normalizeResponsesInput(out.input);
|
||||||
|
if (normalized) out.input = normalized;
|
||||||
|
if (!Array.isArray(out.input) || out.input.length === 0) {
|
||||||
|
out.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }];
|
||||||
|
}
|
||||||
|
// Responses names the output cap max_output_tokens, not max_tokens.
|
||||||
|
if (out.max_output_tokens === undefined) {
|
||||||
|
if (out.max_completion_tokens !== undefined) out.max_output_tokens = out.max_completion_tokens;
|
||||||
|
else if (out.max_tokens !== undefined) out.max_output_tokens = out.max_tokens;
|
||||||
|
}
|
||||||
|
delete out.max_tokens;
|
||||||
|
delete out.max_completion_tokens;
|
||||||
|
if (out.reasoning_effort !== undefined && out.reasoning === undefined) {
|
||||||
|
out.reasoning = { effort: out.reasoning_effort, summary: "auto" };
|
||||||
|
}
|
||||||
|
if (out.reasoning && typeof out.reasoning === "object" && !Array.isArray(out.reasoning)) {
|
||||||
|
if (!out.reasoning.summary) out.reasoning.summary = "auto";
|
||||||
|
}
|
||||||
|
delete out.reasoning_effort;
|
||||||
|
out.stream = true;
|
||||||
|
out.store = false;
|
||||||
|
ensureResponsesFingerprintTools(out);
|
||||||
|
normalizeResponsesTools(out);
|
||||||
|
sanitizeResponsesItems(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js";
|
|||||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||||
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
||||||
|
import { applyFingerprintTools } from "../utils/opencodeFingerprint.js";
|
||||||
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
||||||
import {
|
import {
|
||||||
normalizeResponsesInput,
|
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}$/;
|
export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
|
||||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
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) {
|
function hasValidOpencodeVersion(ua) {
|
||||||
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
||||||
if (!m) return false;
|
if (!m) return false;
|
||||||
@@ -499,11 +438,12 @@ export class OpenCodeExecutor extends BaseExecutor {
|
|||||||
body.store = false;
|
body.store = false;
|
||||||
normalizeResponsesTools(body);
|
normalizeResponsesTools(body);
|
||||||
sanitizeResponsesItems(body);
|
sanitizeResponsesItems(body);
|
||||||
if (!Array.isArray(body.tools) || body.tools.length === 0) {
|
// Free-tier fingerprint tools are required even when an agent client
|
||||||
cloakOpencodeTools(body, true);
|
// 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") {
|
} else if (body && typeof body === "object") {
|
||||||
cloakOpencodeTools(body, false);
|
applyFingerprintTools(body, false);
|
||||||
}
|
}
|
||||||
return injectReasoningContent({ provider: this.provider, model, body });
|
return injectReasoningContent({ provider: this.provider, model, body });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { BaseExecutor } from "./base.js";
|
|||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import { SSE_DONE } from "../utils/sseConstants.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 { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||||
import {
|
import {
|
||||||
QODER_CHAT_SIG_PATH,
|
QODER_CHAT_SIG_PATH,
|
||||||
@@ -208,16 +208,16 @@ function truncate(s, n) {
|
|||||||
/**
|
/**
|
||||||
* Map the OpenAI-style request body into the exact shape Qoder expects.
|
* 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\//, "");
|
const qoderKey = String(model || "").replace(/^qoder\//, "");
|
||||||
|
|
||||||
// Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP.
|
// 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.
|
// 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) {
|
if (!modelConfig) {
|
||||||
// Try a forced refresh once before giving up — the cache may simply
|
// Try a forced refresh once before giving up — the cache may simply
|
||||||
// not be populated yet on first ever call for this credential.
|
// 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);
|
const retried = refreshed?.rawConfigs.get(qoderKey);
|
||||||
if (!retried) {
|
if (!retried) {
|
||||||
throw new Error(
|
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.
|
* 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) {
|
function isBillingBlock(inner) {
|
||||||
if (!inner || typeof inner !== "string") return false;
|
if (!inner || typeof inner !== "string") return false;
|
||||||
const lowerMsg = inner.toLowerCase();
|
const lowerMsg = inner.toLowerCase();
|
||||||
// Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field
|
if (lowerMsg.includes("pricingurl")) return true;
|
||||||
return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl");
|
// 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.
|
* Peek the first SSE data line to detect upstream errors before piping.
|
||||||
* Returns { isBilling, statusVal, message, consumed } — `consumed` is every
|
* Returns { isError, isBilling, statusVal, message, consumed } — `consumed` is every
|
||||||
* byte read so far (including the peeked line) so the caller can re-process
|
* byte read so far (including the peeked line) so the caller can re-process
|
||||||
* it and nothing is dropped from the stream.
|
* it and nothing is dropped from the stream.
|
||||||
*/
|
*/
|
||||||
async function peekFirstQoderFrame(reader, decoder) {
|
async function peekFirstQoderFrame(reader, decoder) {
|
||||||
let consumed = "";
|
let consumed = "";
|
||||||
|
let offset = 0;
|
||||||
|
let upstreamDone = false;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
let nl = consumed.indexOf("\n", offset);
|
||||||
if (done) return { isBilling: false, consumed, upstreamDone: true };
|
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 line = consumed.slice(offset, nl).replace(/\r$/, "").trim();
|
||||||
const nl = consumed.indexOf("\n");
|
offset = nl + 1;
|
||||||
if (nl === -1) continue; // need a full line first
|
|
||||||
|
|
||||||
const line = consumed.slice(0, nl).replace(/\r$/, "").trim();
|
|
||||||
if (!line.startsWith("data:")) continue;
|
if (!line.startsWith("data:")) continue;
|
||||||
|
|
||||||
const data = line.slice(5).trimStart();
|
const data = line.slice(5).trimStart();
|
||||||
if (data === "[DONE]") return { isBilling: false, consumed };
|
if (data === "[DONE]") return { isError: false, consumed, upstreamDone };
|
||||||
|
|
||||||
let envelope;
|
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;
|
// statusCodeValue is documented numeric, but accept numeric strings defensively.
|
||||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
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)) {
|
if (statusVal !== 200) {
|
||||||
return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` };
|
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:
|
* Each upstream line looks like:
|
||||||
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
|
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
|
||||||
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
|
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
|
||||||
* and re-emit as `data: <inner>\n\n`. Errors become a synthetic OpenAI error
|
* and re-emit as `data: <inner>\n\n`. First-frame errors become HTTP errors;
|
||||||
* chunk + [DONE].
|
* errors after streaming starts retain the synthetic chunk + [DONE] path.
|
||||||
*
|
*
|
||||||
* Critical: Qoder's SSE often keeps the socket open after the terminal
|
* Critical: Qoder's SSE often keeps the socket open after the terminal
|
||||||
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
|
* [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
|
* usage from the finish chunk, so we coalesce those two frames (see
|
||||||
* createQoderSseCoalescer) before forwarding.
|
* createQoderSseCoalescer) before forwarding.
|
||||||
*
|
*
|
||||||
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
|
* Peek the first frame for errors before committing to HTTP 200. Preserve
|
||||||
* If detected, return 403 response so chatCore marks connection unavailable
|
* upstream error statuses so chatCore can handle failures instead of recording
|
||||||
* and triggers combo fallback instead of leaking error text into chat.
|
* 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;
|
if (!response.ok || !response.body) return response;
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const reader = response.body.getReader();
|
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);
|
const peek = await peekFirstQoderFrame(reader, decoder);
|
||||||
if (peek?.isBilling) {
|
if (peek.isError) {
|
||||||
// Billing block detected — return 403 so chatCore fails this connection
|
|
||||||
await reader.cancel().catch(() => {});
|
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(
|
return new Response(
|
||||||
JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }),
|
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;
|
let envelope;
|
||||||
try { envelope = JSON.parse(data); } catch { return; }
|
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"
|
const inner = typeof envelope.body === "string"
|
||||||
? envelope.body
|
? envelope.body
|
||||||
: envelope.body != null ? JSON.stringify(envelope.body) : "";
|
: envelope.body != null ? JSON.stringify(envelope.body) : "";
|
||||||
if (statusVal !== 200) {
|
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 msg = inner || `upstream status ${statusVal}`;
|
||||||
const errChunk = JSON.stringify({
|
const errChunk = JSON.stringify({
|
||||||
id: `qoder-error-${Date.now()}`,
|
id: `qoder-error-${Date.now()}`,
|
||||||
@@ -552,12 +598,13 @@ async function wrapQoderSSE(response, model) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class QoderExecutor extends BaseExecutor {
|
export class QoderExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor(provider = "qoder") {
|
||||||
super("qoder", PROVIDERS.qoder);
|
super(provider, PROVIDERS[provider]);
|
||||||
|
this.region = provider === "qoder-cn" ? "cn" : "intl";
|
||||||
}
|
}
|
||||||
|
|
||||||
buildUrl(credentials) {
|
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:
|
// Override execute entirely — Qoder needs:
|
||||||
@@ -572,7 +619,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
||||||
if (isQoderPat(rawToken)) {
|
if (isQoderPat(rawToken)) {
|
||||||
try {
|
try {
|
||||||
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal);
|
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal, this.region);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
|
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
|
||||||
const fakeResp = new Response(
|
const fakeResp = new Response(
|
||||||
@@ -607,7 +654,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
let qoderKey;
|
let qoderKey;
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
const fakeResp = new Response(
|
const fakeResp = new Response(
|
||||||
JSON.stringify({ error: { message: err.message } }),
|
JSON.stringify({ error: { message: err.message } }),
|
||||||
@@ -666,8 +713,15 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
response = await proxyAwareFetch(
|
response = await proxyAwareFetch(
|
||||||
url,
|
url,
|
||||||
{ method: "POST", headers, body: encodedBodyBuf, signal: mergedSignal },
|
{ 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 {
|
} finally {
|
||||||
clearTimeout(connectTimer);
|
clearTimeout(connectTimer);
|
||||||
}
|
}
|
||||||
@@ -677,7 +731,7 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
return { response, url, headers, transformedBody: payload };
|
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 };
|
return { response: wrapped, url, headers, transformedBody: payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { DefaultExecutor } from "./default.js";
|
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
|
// Dual-route v2.6 models.
|
||||||
// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key).
|
// v2.6 models dynamically route to the account service when desktop session credentials
|
||||||
// See shared/mimoAccount.js for the session handshake.
|
// (mimoPassToken or account cookie) are present to consume weekly quota, falling back to
|
||||||
const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]);
|
// 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()
|
// Session cookie resolved in execute() (async) and read back by buildHeaders()
|
||||||
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
|
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
|
||||||
@@ -23,15 +28,24 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
super("xiaomi-mimo");
|
super("xiaomi-mimo");
|
||||||
}
|
}
|
||||||
|
|
||||||
static isPreviewModel(model) {
|
static isAccountRoute(model, credentials) {
|
||||||
return PREVIEW_MODELS.has(bareModel(model));
|
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) {
|
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.
|
// declared transports — resolve it before the default runtimeTransport path.
|
||||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
if (this.isAccountRoute(model, credentials)) {
|
||||||
return `${MIMO_API_BASE}/api/route/chat/completions`;
|
return `${resolveMimoServerBase(credentials?.providerSpecificData)}/api/route/chat/completions`;
|
||||||
}
|
}
|
||||||
// Cloud API models keep default handling, so a Claude-format client reaches
|
// Cloud API models keep default handling, so a Claude-format client reaches
|
||||||
// the /anthropic/v1/messages transport.
|
// the /anthropic/v1/messages transport.
|
||||||
@@ -39,8 +53,8 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildHeaders(credentials, stream = true, url, model) {
|
buildHeaders(credentials, stream = true, url, model) {
|
||||||
if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) {
|
if (this.isAccountRoute(model, credentials) && credentials?.[COOKIE_KEY]) {
|
||||||
// Preview models authenticate with the account-session cookie, not the key.
|
// Account route models authenticate with the account-session cookie, not the key.
|
||||||
return {
|
return {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: stream ? "text/event-stream" : "application/json",
|
Accept: stream ? "text/event-stream" : "application/json",
|
||||||
@@ -52,17 +66,22 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
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).
|
// arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js).
|
||||||
const out = super.transformRequest(model, body, stream, credentials);
|
const out = super.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
// Preview models: thinking/params get defaults only — never override what the
|
// Account route models: bridge reasoning_effort to official output_config.effort
|
||||||
// caller set explicitly. (body.model is already `xiaomi/<id>` via upstreamModelId.)
|
// (matches MiMo Desktop app.asar behavior).
|
||||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
if (this.isAccountRoute(model, credentials)) {
|
||||||
if (out.thinking == null) out.thinking = { type: "enabled" };
|
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.temperature == null) out.temperature = 1.0;
|
||||||
if (out.top_p == null) out.top_p = 0.95;
|
if (out.top_p == null) out.top_p = 0.95;
|
||||||
if (!out.max_tokens) out.max_tokens = 4096;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
@@ -70,13 +89,11 @@ export class XiaomiMimoExecutor extends DefaultExecutor {
|
|||||||
|
|
||||||
async execute(args) {
|
async execute(args) {
|
||||||
const { model, credentials, proxyOptions = null } = 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);
|
const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions);
|
||||||
if (!cookie) {
|
if (!cookie) {
|
||||||
throw new Error(
|
return super.execute(args);
|
||||||
"Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
credentials[COOKIE_KEY] = cookie;
|
credentials[COOKIE_KEY] = cookie;
|
||||||
const result = await super.execute(args);
|
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;
|
export default XiaomiMimoExecutor;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
|||||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
||||||
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
||||||
import { dedupeTools } from "../utils/toolDeduper.js";
|
import { dedupeTools } from "../utils/toolDeduper.js";
|
||||||
|
import { takeRenamedToolNames } from "../utils/opencodeFingerprint.js";
|
||||||
import { injectCaveman } from "../rtk/caveman.js";
|
import { injectCaveman } from "../rtk/caveman.js";
|
||||||
import { injectPonytail } from "../rtk/ponytail.js";
|
import { injectPonytail } from "../rtk/ponytail.js";
|
||||||
import { compressMessages, formatRtkLog } from "../rtk/index.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 clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
|
||||||
const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
|
const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
|
||||||
let stream = providerRequiresStreaming ? true : (body.stream !== false);
|
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);
|
translatedBody.tools = defaultClaudeToolType(translatedBody.tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-request opt-out: client can bypass all token savers via header
|
// RTK: compress tool_result content. Skipped when already done pre-translate.
|
||||||
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
|
const rtkStats = preTranslateRtk || compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
|
||||||
|
|
||||||
// RTK: compress tool_result content
|
|
||||||
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
|
|
||||||
const rtkLine = formatRtkLog(rtkStats);
|
const rtkLine = formatRtkLog(rtkStats);
|
||||||
if (rtkLine) log?.info?.("RTK", rtkLine.replace(/^\[RTK\] /, ""));
|
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.
|
// Token-saver flags accumulator for the single "⚙" log line below.
|
||||||
const xf = [];
|
const xf = [];
|
||||||
|
|
||||||
|
if (rtkStats?.hits?.length) xf.push(`RTK:${rtkStats.hits.length}`);
|
||||||
|
|
||||||
// Caveman: inject terse-style system prompt
|
// Caveman: inject terse-style system prompt
|
||||||
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
|
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
|
||||||
injectCaveman(translatedBody, finalFormat, cavemanLevel);
|
injectCaveman(translatedBody, finalFormat, cavemanLevel);
|
||||||
@@ -378,6 +391,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
providerHeaders = result.headers;
|
providerHeaders = result.headers;
|
||||||
finalBody = result.transformedBody;
|
finalBody = result.transformedBody;
|
||||||
providerResponseFormat = result.responseFormat || targetFormat;
|
providerResponseFormat = result.responseFormat || targetFormat;
|
||||||
|
const renamedToolNames = takeRenamedToolNames(translatedBody);
|
||||||
|
if (renamedToolNames?.size) {
|
||||||
|
toolNameMap = new Map([...(toolNameMap || []), ...renamedToolNames]);
|
||||||
|
}
|
||||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackPendingRequest(model, provider, connectionId, false, true);
|
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
|
// Provider forced streaming but client wants JSON
|
||||||
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
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; }
|
if (result) { streamController.handleComplete(); return result; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, sav
|
|||||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
|
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
|
||||||
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
||||||
|
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
|
||||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
||||||
|
|
||||||
function parseToolArguments(value) {
|
function parseToolArguments(value) {
|
||||||
@@ -415,7 +416,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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": "*" }
|
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
||||||
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
|
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
|
||||||
|
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
|
||||||
import { createErrorResult } from "../../utils/error.js";
|
import { createErrorResult } from "../../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||||
import { FORMATS } from "../../translator/formats.js";
|
import { FORMATS } from "../../translator/formats.js";
|
||||||
@@ -215,17 +216,13 @@ export async function handleForcedSSEToJson({
|
|||||||
clientRawRequest,
|
clientRawRequest,
|
||||||
onRequestSuccess,
|
onRequestSuccess,
|
||||||
customToolNames,
|
customToolNames,
|
||||||
|
toolNameMap,
|
||||||
trackDone,
|
trackDone,
|
||||||
appendLog,
|
appendLog,
|
||||||
reqTag,
|
reqTag,
|
||||||
log,
|
log,
|
||||||
streamErrorPatterns,
|
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();
|
trackDone();
|
||||||
|
|
||||||
@@ -306,7 +303,7 @@ export async function handleForcedSSEToJson({
|
|||||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(JSON.stringify(jsonResponse), {
|
response: new Response(JSON.stringify(restoreToolNames(jsonResponse, toolNameMap)), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
@@ -406,7 +403,7 @@ export async function handleForcedSSEToJson({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(JSON.stringify(finalResp), {
|
response: new Response(JSON.stringify(restoreToolNames(finalResp, toolNameMap)), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
@@ -432,8 +429,16 @@ export async function handleForcedSSEToJson({
|
|||||||
"Invalid SSE response for non-streaming request",
|
"Invalid SSE response for non-streaming request",
|
||||||
);
|
);
|
||||||
if (parsed.error) {
|
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(
|
return createErrorResult(
|
||||||
HTTP_STATUS.BAD_GATEWAY,
|
status,
|
||||||
parsed.error.message || "Upstream SSE stream failed",
|
parsed.error.message || "Upstream SSE stream failed",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -508,7 +513,7 @@ export async function handleForcedSSEToJson({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(JSON.stringify(finalBody), {
|
response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
|||||||
@@ -1,18 +1,93 @@
|
|||||||
// HuggingFace Inference API — returns binary image
|
// HuggingFace Inference Providers router — returns binary image
|
||||||
import { nowSec } from "./_base.js";
|
//
|
||||||
|
// The router is a switchboard in front of many inference providers and is
|
||||||
|
// addressed as `<baseUrl>/<provider>/<providerModelId>`. `providerModelId` is
|
||||||
|
// the id the *provider* uses, which is not the Hub model id, so it is resolved
|
||||||
|
// through `imageConfig.modelMap` (built from the Hub API's
|
||||||
|
// inferenceProviderMapping and limited to providers the router forwards to).
|
||||||
|
//
|
||||||
|
// The legacy `api-inference.huggingface.co` host is gone (DNS ENOTFOUND) and is
|
||||||
|
// deliberately not referenced anywhere here.
|
||||||
|
import { nowSec, urlToBase64 } from "./_base.js";
|
||||||
import { PROVIDER_MEDIA } from "../../providers/index.js";
|
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 {
|
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) => {
|
buildHeaders: (creds) => {
|
||||||
const headers = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json" };
|
||||||
const key = creds?.apiKey || creds?.accessToken;
|
const key = creds?.apiKey || creds?.accessToken;
|
||||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||||
return headers;
|
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
|
// HF returns raw image bytes — convert to b64_json
|
||||||
async parseResponse(response) {
|
async parseResponse(response) {
|
||||||
const buf = await response.arrayBuffer();
|
const buf = await response.arrayBuffer();
|
||||||
|
|||||||
95
open-sse/handlers/systemoneCore.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||||
|
import { HTTP_STATUS, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
||||||
|
import { PROVIDER_MEDIA } from "../providers/index.js";
|
||||||
|
import { generateSessionId } from "../executors/opencode-zen.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core System One (Jev) handler — native decision payload pass-through.
|
||||||
|
* URL/headers come from the registry's systemoneConfig; body and JSON response
|
||||||
|
* are forwarded untouched (decision models have no chat translation layer).
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ success: boolean, response: Response, usage?: object, status?: number, error?: string }>}
|
||||||
|
*/
|
||||||
|
export async function handleSystemoneCore({
|
||||||
|
body,
|
||||||
|
modelInfo,
|
||||||
|
credentials,
|
||||||
|
log,
|
||||||
|
onRequestSuccess,
|
||||||
|
}) {
|
||||||
|
const { provider, model } = modelInfo;
|
||||||
|
const cfg = PROVIDER_MEDIA[provider]?.systemoneConfig;
|
||||||
|
if (!cfg?.baseUrl) {
|
||||||
|
return createErrorResult(
|
||||||
|
HTTP_STATUS.BAD_REQUEST,
|
||||||
|
`Provider '${provider}' does not support System One.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate input at the trust boundary; question-level shape is upstream's job.
|
||||||
|
if (body.state === undefined || body.state === null) {
|
||||||
|
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: state");
|
||||||
|
}
|
||||||
|
if (!body.questions || typeof body.questions !== "object" || Array.isArray(body.questions)) {
|
||||||
|
return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: questions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// noAuth free lanes carry accessToken "public" from the credential stub.
|
||||||
|
const token = credentials?.apiKey || credentials?.accessToken;
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(cfg.headers || {}),
|
||||||
|
// Zen lanes expect the official client session header on every request.
|
||||||
|
"x-opencode-session": generateSessionId(),
|
||||||
|
};
|
||||||
|
const requestBody = { ...body, model };
|
||||||
|
|
||||||
|
log?.debug?.("SYSTEMONE", `${provider.toUpperCase()} | ${model}`);
|
||||||
|
|
||||||
|
let providerResponse;
|
||||||
|
try {
|
||||||
|
providerResponse = await fetch(cfg.baseUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
...(typeof AbortSignal?.timeout === "function"
|
||||||
|
? { signal: AbortSignal.timeout(FETCH_CONNECT_TIMEOUT_MS) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
||||||
|
log?.debug?.("SYSTEMONE", `Fetch error: ${errMsg}`);
|
||||||
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providerResponse.ok) {
|
||||||
|
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
||||||
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
||||||
|
log?.debug?.("SYSTEMONE", `Provider error: ${errMsg}`);
|
||||||
|
return createErrorResult(statusCode, errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseBody;
|
||||||
|
try {
|
||||||
|
responseBody = await providerResponse.json();
|
||||||
|
} catch {
|
||||||
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onRequestSuccess) await onRequestSuccess();
|
||||||
|
|
||||||
|
const usage = responseBody?.usage;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
usage: usage
|
||||||
|
? { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 }
|
||||||
|
: null,
|
||||||
|
response: new Response(JSON.stringify(responseBody), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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-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.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-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'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 },
|
"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-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
|
||||||
"deepseek-ai/deepseek-v4-flash": { 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": {
|
"codex": {
|
||||||
"gpt-6-astra": { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 },
|
"gpt-6-astra": { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 },
|
||||||
"gpt-5.6-sol": CODEX_GPT_56_SOL_CAPS,
|
"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 },
|
"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.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.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
|
// Per-model values mirror the server's product-config payload (the plugin
|
||||||
// fetches it from copilot.tencent.com; the `models[]` entries carry
|
// fetches it from copilot.tencent.com; the `models[]` entries carry
|
||||||
// maxInputTokens/maxOutputTokens/supportsImages). contextWindow =
|
// maxInputTokens/maxOutputTokens/supportsImages). contextWindow =
|
||||||
@@ -226,45 +238,6 @@ export const PROVIDER_CAPABILITIES = {
|
|||||||
// contract). maxOutput 128000 per the server's product-config payload.
|
// 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 },
|
"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 — OpenAI-compatible, all reasoning-capable (32K max output).
|
||||||
"poolside": {
|
"poolside": {
|
||||||
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
|
"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
|
* Pattern fallback — glob (* = wildcard), matched case-insensitively and
|
||||||
* anchored (^...$) so a pattern must match the full model id. ORDER MATTERS:
|
* 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*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*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*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.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.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 } },
|
{ 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) ─────────────────
|
// ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
|
||||||
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
|
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
|
||||||
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } },
|
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1000000, maxOutput: 131072 } },
|
||||||
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, 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 } },
|
{ pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
|
||||||
|
|
||||||
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
|
// ── Xiaomi MiMo (vision + <think>-tag reasoning, always-on, can't disable) ──
|
||||||
{ pattern: "*mimo*v2.5*", caps: { vision: true, audioInput: true, videoInput: true, contextWindow: 1048576, maxOutput: 131072 } },
|
{ pattern: "*mimo*v2.6*", 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, contextWindow: 262144, 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*", caps: { vision: true, contextWindow: 262144, 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) ──────────────────
|
// ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
|
||||||
{ pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
|
{ 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.
|
// unknown models on these providers, trust vision instead of stripping images.
|
||||||
const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
|
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,
|
* Resolve capabilities for a model using the 4-step fallback chain,
|
||||||
* merged over DEFAULT_CAPABILITIES so the result is always complete.
|
* merged over DEFAULT_CAPABILITIES so the result is always complete.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function buildTransport(transport, oauth) {
|
|||||||
const MEDIA_KEYS = new Set([
|
const MEDIA_KEYS = new Set([
|
||||||
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
|
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
|
||||||
"imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
|
"imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
|
||||||
"searchViaChat", "searchConfig", "fetchConfig",
|
"searchViaChat", "searchConfig", "fetchConfig", "systemoneConfig",
|
||||||
"modelsFetcher", "mediaPriority", "hiddenKinds",
|
"modelsFetcher", "mediaPriority", "hiddenKinds",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
|
{ id: "claude-opus-5-5", name: "Claude Opus 5.5" },
|
||||||
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||||
{ id: "claude-fable-5-1", name: "Claude Fable 5.1" },
|
{ id: "claude-fable-5-1", name: "Claude Fable 5.1" },
|
||||||
{ id: "claude-fable-5", name: "Claude Fable 5" },
|
{ id: "claude-fable-5", name: "Claude Fable 5" },
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default {
|
|||||||
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||||
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
{ 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", 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.6", name: "Kimi K2.6" },
|
||||||
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
|
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
|
||||||
{ id: "zai-org/GLM-5.2", name: "GLM 5.2" },
|
{ 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: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
|
||||||
{ id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
{ id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||||
{ id: "xiaomi/mimo-v2.5", name: "MiMo V2.5" },
|
{ 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-Max-Preview", name: "Qwen 3.6 Max Preview" },
|
||||||
{ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" },
|
{ 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.7-Flash", name: "Step 3.7 Flash" },
|
||||||
{ id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
|
{ id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
|
||||||
{ id: "tencent/Hy3", name: "Tencent Hy3" },
|
{ 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: "thinkingmachines/inkling", name: "Inkling" },
|
||||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default {
|
|||||||
website: "https://huggingface.co",
|
website: "https://huggingface.co",
|
||||||
notice: {
|
notice: {
|
||||||
apiKeyUrl: "https://huggingface.co/settings/tokens",
|
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",
|
category: "apikey",
|
||||||
@@ -25,10 +26,79 @@ export default {
|
|||||||
transport: null,
|
transport: null,
|
||||||
models: [
|
models: [
|
||||||
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", params: [], kind: "image" },
|
{ 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: "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: "stabilityai/stable-diffusion-3.5-large", name: "Stable Diffusion 3.5 Large", params: [], kind: "image" },
|
||||||
{ id: "openai/whisper-small", name: "Whisper Small (HF)", params: ["language"], kind: "stt" },
|
{ 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"],
|
serviceKinds: ["image", "stt"],
|
||||||
imageConfig: { baseUrl: "https://api-inference.huggingface.co/models" },
|
// Inference Providers router. The router is addressed as
|
||||||
|
// `<baseUrl>/<provider>/<providerModelId>` — see open-sse/handlers/imageProviders/huggingface.js.
|
||||||
|
// `modelMap` resolves a Hub model id to the provider-resolved id the router expects.
|
||||||
|
// A plain string value is the provider path. Image-to-image models use
|
||||||
|
// `{ path, task: "image-to-image" }`: their payload differs — the source image goes in
|
||||||
|
// `inputs` and the prompt under `parameters.prompt`. See
|
||||||
|
// https://huggingface.co/docs/inference-providers/tasks/image-to-image
|
||||||
|
// Only providers the router actually forwards to are listed: replicate, wavespeed and
|
||||||
|
// deepinfra appear in the Hub's inferenceProviderMapping but reject router traffic with
|
||||||
|
// "Model not supported by provider <name>".
|
||||||
|
imageConfig: {
|
||||||
|
baseUrl: "https://router.huggingface.co",
|
||||||
|
modelMap: {
|
||||||
|
"black-forest-labs/FLUX.1-schnell": "fal-ai/fal-ai/flux/schnell",
|
||||||
|
"black-forest-labs/FLUX.1-dev": "fal-ai/fal-ai/flux/dev",
|
||||||
|
"black-forest-labs/FLUX.1-Krea-dev": "fal-ai/fal-ai/flux/krea",
|
||||||
|
"black-forest-labs/FLUX.1-Kontext-dev": { path: "fal-ai/fal-ai/flux-kontext/dev", task: "image-to-image" },
|
||||||
|
"black-forest-labs/FLUX.2-dev": { path: "fal-ai/fal-ai/flux-2/edit", task: "image-to-image" },
|
||||||
|
"black-forest-labs/FLUX.2-klein-9B": { path: "fal-ai/fal-ai/flux-2/klein/9b/edit", task: "image-to-image" },
|
||||||
|
"black-forest-labs/FLUX.2-klein-4B": { path: "fal-ai/fal-ai/flux-2/klein/4b/distilled/edit", task: "image-to-image" },
|
||||||
|
"black-forest-labs/FLUX.2-klein-base-9B": { path: "fal-ai/fal-ai/flux-2/klein/9b/base/edit", task: "image-to-image" },
|
||||||
|
"black-forest-labs/FLUX.2-klein-base-4B": { path: "fal-ai/fal-ai/flux-2/klein/4b/base/edit", task: "image-to-image" },
|
||||||
|
"stabilityai/stable-diffusion-xl-base-1.0": "fal-ai/fal-ai/fast-sdxl",
|
||||||
|
"stabilityai/stable-diffusion-3.5-large": "fal-ai/fal-ai/stable-diffusion-v35-large",
|
||||||
|
"stabilityai/stable-diffusion-3.5-large-turbo": "fal-ai/fal-ai/stable-diffusion-v35-large/turbo",
|
||||||
|
"Qwen/Qwen-Image": "fal-ai/fal-ai/qwen-image",
|
||||||
|
"Qwen/Qwen-Image-2512": "fal-ai/fal-ai/qwen-image-2512",
|
||||||
|
"Qwen/Qwen-Image-Edit": { path: "fal-ai/fal-ai/qwen-image-edit", task: "image-to-image" },
|
||||||
|
"Qwen/Qwen-Image-Edit-2509": { path: "fal-ai/fal-ai/qwen-image-edit-2509", task: "image-to-image" },
|
||||||
|
"Qwen/Qwen-Image-Edit-2511": { path: "fal-ai/fal-ai/qwen-image-edit-plus", task: "image-to-image" },
|
||||||
|
"ideogram-ai/ideogram-4-fp8": "fal-ai/ideogram/v4",
|
||||||
|
"tencent/HunyuanImage-3.0": "fal-ai/fal-ai/hunyuan-image/v3/text-to-image",
|
||||||
|
"Tongyi-MAI/Z-Image-Turbo": "fal-ai/fal-ai/z-image/turbo",
|
||||||
|
"krea/Krea-2-Turbo": "fal-ai/fal-ai/krea-2/turbo",
|
||||||
|
"HiDream-ai/HiDream-I1-Fast": "fal-ai/fal-ai/hidream-i1-fast",
|
||||||
|
"playgroundai/playground-v2.5-1024px-aesthetic": "fal-ai/fal-ai/playground-v25",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Speech-to-text goes through the hf-inference provider, which keeps the Hub
|
||||||
|
// model id as its provider-resolved id (`/hf-inference/models/<hubId>`).
|
||||||
|
// No `params` are declared: the router's ASR payload carries only `inputs` and
|
||||||
|
// `parameters.return_timestamps` / `parameters.generation_parameters` — it has no
|
||||||
|
// language field, so a UI-declared "language" would be silently dropped.
|
||||||
|
sttConfig: {
|
||||||
|
baseUrl: "https://router.huggingface.co/hf-inference/models",
|
||||||
|
authType: "apikey",
|
||||||
|
authHeader: "bearer",
|
||||||
|
format: "huggingface-asr",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import p66 from "./ollama.js";
|
|||||||
import p123 from "./ollama-search.js";
|
import p123 from "./ollama-search.js";
|
||||||
import p67 from "./openai.js";
|
import p67 from "./openai.js";
|
||||||
import p68 from "./opencode-go.js";
|
import p68 from "./opencode-go.js";
|
||||||
|
import p68z from "./opencode-zen.js";
|
||||||
import p69 from "./opencode.js";
|
import p69 from "./opencode.js";
|
||||||
import p70 from "./openrouter.js";
|
import p70 from "./openrouter.js";
|
||||||
import p71 from "./perplexity-web.js";
|
import p71 from "./perplexity-web.js";
|
||||||
@@ -76,6 +77,7 @@ import p72 from "./perplexity.js";
|
|||||||
import p73 from "./perplexity-agent.js";
|
import p73 from "./perplexity-agent.js";
|
||||||
import p74 from "./playht.js";
|
import p74 from "./playht.js";
|
||||||
import p75 from "./qoder.js";
|
import p75 from "./qoder.js";
|
||||||
|
import p124 from "./qoder-cn.js";
|
||||||
import p77 from "./recraft.js";
|
import p77 from "./recraft.js";
|
||||||
import p78 from "./runwayml.js";
|
import p78 from "./runwayml.js";
|
||||||
import p79 from "./sdwebui.js";
|
import p79 from "./sdwebui.js";
|
||||||
@@ -192,8 +194,10 @@ export default [
|
|||||||
p65,
|
p65,
|
||||||
p66,
|
p66,
|
||||||
p123,
|
p123,
|
||||||
|
p124,
|
||||||
p67,
|
p67,
|
||||||
p68,
|
p68,
|
||||||
|
p68z,
|
||||||
p69,
|
p69,
|
||||||
p70,
|
p70,
|
||||||
p71,
|
p71,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default {
|
|||||||
forceStream: true,
|
forceStream: true,
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
|
{ id: "gpt-5.5", name: "GPT-5.5" },
|
||||||
{ id: "gpt-5.4", name: "GPT-5.4" },
|
{ id: "gpt-5.4", name: "GPT-5.4" },
|
||||||
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
|
||||||
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },
|
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },
|
||||||
|
|||||||
135
open-sse/providers/registry/opencode-zen.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
export default {
|
||||||
|
id: "opencode-zen",
|
||||||
|
priority: 205,
|
||||||
|
alias: "ocz",
|
||||||
|
aliases: [
|
||||||
|
"opencode-zen",
|
||||||
|
],
|
||||||
|
uiAlias: "ocz",
|
||||||
|
display: {
|
||||||
|
name: "OpenCode Zen",
|
||||||
|
icon: "terminal",
|
||||||
|
color: "#E87040",
|
||||||
|
textIcon: "OZ",
|
||||||
|
website: "https://opencode.ai/auth",
|
||||||
|
notice: {
|
||||||
|
text: "OpenCode Zen PAYG: pay-as-you-go, key from https://opencode.ai/auth. Same models as Zen: paid + free tiers on the fast lane.",
|
||||||
|
apiKeyUrl: "https://opencode.ai/auth",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "apikey",
|
||||||
|
transport: {
|
||||||
|
baseUrl: "https://opencode.ai/zen/v1/chat/completions",
|
||||||
|
headers: {},
|
||||||
|
usage: {
|
||||||
|
url: "https://opencode.ai/zen/v1/usage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
|
||||||
|
// translation. Mirrors opencode-go, pointed at /zen/v1 (see https://opencode.ai/docs/zen/).
|
||||||
|
transports: [
|
||||||
|
{ format: "openai", baseUrl: "https://opencode.ai/zen/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||||
|
{ format: "claude", baseUrl: "https://opencode.ai/zen/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
|
||||||
|
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||||
|
],
|
||||||
|
// supportedFormats follow the endpoint table in https://opencode.ai/docs/zen/
|
||||||
|
// (live /zen/v1/models, 2026-09-18: 71 ids).
|
||||||
|
models: [
|
||||||
|
// Claude (messages)
|
||||||
|
{ id: "claude-fable-5", name: "Claude Fable 5", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-fable-5-1", name: "Claude Fable 5.1", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-opus-5", name: "Claude Opus 5", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", supportedFormats: ["claude"] },
|
||||||
|
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", supportedFormats: ["claude"] },
|
||||||
|
// Gemini (own path, via chat completions transport)
|
||||||
|
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash", supportedFormats: ["openai"] },
|
||||||
|
{ id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", supportedFormats: ["openai"] },
|
||||||
|
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", supportedFormats: ["openai"] },
|
||||||
|
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite", supportedFormats: ["openai"] },
|
||||||
|
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash", supportedFormats: ["openai"] },
|
||||||
|
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", supportedFormats: ["openai"] },
|
||||||
|
{ id: "gemini-3-flash", name: "Gemini 3 Flash", supportedFormats: ["openai"] },
|
||||||
|
// GPT / Grok / Muse Spark paid (responses)
|
||||||
|
{ id: "gpt-6-astra", name: "GPT 6 Astra", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.5", name: "GPT 5.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.5-pro", name: "GPT 5.5 Pro", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.4-pro", name: "GPT 5.4 Pro", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.4-nano", name: "GPT 5.4 Nano", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.2", name: "GPT 5.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.1", name: "GPT 5.1", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.1-codex-max", name: "GPT 5.1 Codex Max", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.1-codex", name: "GPT 5.1 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5.1-codex-mini", name: "GPT 5.1 Codex Mini", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5", name: "GPT 5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5-codex", name: "GPT 5 Codex", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "gpt-5-nano", name: "GPT 5 Nano", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "grok-build-0.1", name: "Grok Build 0.1", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "grok-4.6", name: "Grok 4.6", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "grok-4.5", name: "Grok 4.5", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "muse-spark-1.3", name: "Muse Spark 1.3", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "muse-spark-1.2", name: "Muse Spark 1.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
// Qwen paid (messages)
|
||||||
|
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["claude"] },
|
||||||
|
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus", supportedFormats: ["claude"] },
|
||||||
|
// DeepSeek / GLM / MiniMax / Kimi / Big Pickle (chat completions)
|
||||||
|
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai"] },
|
||||||
|
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai"] },
|
||||||
|
{ id: "deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision Exp", supportedFormats: ["openai"] },
|
||||||
|
{ id: "glm-5.3-flash", name: "GLM 5.3 Flash (Vision)", supportedFormats: ["openai"] },
|
||||||
|
{ id: "glm-5.3", name: "GLM 5.3", supportedFormats: ["openai"] },
|
||||||
|
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
|
||||||
|
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
|
||||||
|
{ id: "glm-5", name: "GLM 5", supportedFormats: ["openai"] },
|
||||||
|
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai"] },
|
||||||
|
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai"] },
|
||||||
|
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai"] },
|
||||||
|
{ id: "kimi-k3", name: "Kimi K3", supportedFormats: ["openai"] },
|
||||||
|
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
|
||||||
|
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
|
||||||
|
{ id: "kimi-k2.5", name: "Kimi K2.5", supportedFormats: ["openai"] },
|
||||||
|
{ id: "big-pickle", name: "Big Pickle", supportedFormats: ["openai"] },
|
||||||
|
{ id: "union-alpha", name: "Union Alpha", supportedFormats: ["claude"] },
|
||||||
|
// Free tier on the keyed lane (chat completions)
|
||||||
|
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportedFormats: ["openai"] },
|
||||||
|
{ id: "mimo-v2.6-flash-free", name: "MiMo V2.6 Flash Free", supportedFormats: ["openai"] },
|
||||||
|
{ id: "mimo-v2.5-free", name: "MiMo V2.5 Free", supportedFormats: ["openai"] },
|
||||||
|
{ id: "ling-3.0-flash-fin-free", name: "Ling 3.0 Flash Fin Free", supportedFormats: ["openai"] },
|
||||||
|
{ id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", supportedFormats: ["openai"] },
|
||||||
|
{ id: "nemotron-3.5-lightning-free", name: "Nemotron 3.5 Lightning Free", supportedFormats: ["openai"] },
|
||||||
|
// Free tier on the keyed lane (responses)
|
||||||
|
{ id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Contributor Free", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
{ id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
|
||||||
|
// System One (Jev) decision models on the native /systemone endpoint
|
||||||
|
{ id: "jev-1.13", name: "Jev 1.13", kind: "systemone" },
|
||||||
|
{ id: "jev-1.13-free", name: "Jev 1.13 Free", kind: "systemone" },
|
||||||
|
],
|
||||||
|
serviceKinds: ["llm", "systemone"],
|
||||||
|
systemoneConfig: {
|
||||||
|
baseUrl: "https://opencode.ai/zen/v1/systemone",
|
||||||
|
headers: {
|
||||||
|
"x-opencode-client": "desktop",
|
||||||
|
"User-Agent": "opencode/1.18.31",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" },
|
||||||
|
passthroughModels: true,
|
||||||
|
features: {
|
||||||
|
usage: true,
|
||||||
|
usageApikey: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.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: "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: "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" },
|
modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" },
|
||||||
passthroughModels: true,
|
passthroughModels: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: "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: "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: "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: {
|
ttsConfig: {
|
||||||
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||||
defaultModel: "openai/gpt-4o-mini-tts",
|
defaultModel: "openai/gpt-4o-mini-tts",
|
||||||
|
|||||||
61
open-sse/providers/registry/qoder-cn.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export default {
|
||||||
|
id: "qoder-cn",
|
||||||
|
priority: 30,
|
||||||
|
alias: "qdcn",
|
||||||
|
uiAlias: "qdcn",
|
||||||
|
display: {
|
||||||
|
name: "Qoder CN",
|
||||||
|
icon: "water_drop",
|
||||||
|
color: "#EC4899",
|
||||||
|
website: "https://qoder.com.cn",
|
||||||
|
notice: {
|
||||||
|
signupUrl: "https://qoder.com.cn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "oauth",
|
||||||
|
authModes: ["oauth", "apikey"],
|
||||||
|
hasOAuth: true,
|
||||||
|
authHint: "Personal Access Token (pt-...) from https://qoder.com.cn/account/integrations",
|
||||||
|
transport: {
|
||||||
|
baseUrl: "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation",
|
||||||
|
headers: {},
|
||||||
|
timeoutMs: 120000,
|
||||||
|
stallTimeoutMs: 120000,
|
||||||
|
usage: {
|
||||||
|
url: "https://openapi.qoder.com.cn/api/v2/quota/usage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{ id: "ultimate", name: "Ultimate" },
|
||||||
|
{ id: "auto", name: "Auto" },
|
||||||
|
{ id: "performance", name: "Performance" },
|
||||||
|
{ id: "efficient", name: "Efficient" },
|
||||||
|
{ id: "lite", name: "Lite" },
|
||||||
|
{ id: "qmodel_38max", name: "Qwen3.8-Max" },
|
||||||
|
{ id: "qmodel_latest", name: "Qwen3.7-Max" },
|
||||||
|
{ id: "qmodel", name: "Qwen3.7-Plus" },
|
||||||
|
{ id: "qfmodel", name: "Qwen3.8-Flash" },
|
||||||
|
{ id: "kmodel_latest", name: "Kimi-K3" },
|
||||||
|
{ id: "kmodel", name: "Kimi-K2.7-Code" },
|
||||||
|
{ id: "gmodel", name: "GLM-5.3" },
|
||||||
|
{ id: "gfmodel", name: "GLM-5.3-Flash" },
|
||||||
|
{ id: "dmodel", name: "DeepSeek-V4-Pro" },
|
||||||
|
{ id: "dfmodel", name: "DeepSeek-V4-Flash" },
|
||||||
|
{ id: "mmodel", name: "MiniMax-M3" },
|
||||||
|
],
|
||||||
|
oauth: {
|
||||||
|
openApiBaseUrl: "https://openapi.qoder.com.cn",
|
||||||
|
centerBaseUrl: "https://gateway.qoder.com.cn",
|
||||||
|
chatBaseUrl: "https://gateway.qoder.com.cn",
|
||||||
|
deviceTokenUrl: "https://openapi.qoder.com.cn/api/v1/deviceToken/poll",
|
||||||
|
refreshUrl: "https://gateway.qoder.com.cn/algo/api/v3/user/refresh_token",
|
||||||
|
userInfoUrl: "https://openapi.qoder.com.cn/api/v1/userinfo",
|
||||||
|
quotaUsageUrl: "https://openapi.qoder.com.cn/api/v2/quota/usage",
|
||||||
|
loginUrl: "https://qoder.com.cn/device/selectAccounts",
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
usage: true,
|
||||||
|
// PAT (apikey) connections also carry quota usage (via job-token exchange).
|
||||||
|
usageApikey: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,9 +2,9 @@ import { CLAUDE_API_HEADERS } from "../shared.js";
|
|||||||
|
|
||||||
// Dual auth (same pattern as kimi):
|
// Dual auth (same pattern as kimi):
|
||||||
// - API key (sk-...) → cloud API on api.xiaomimimo.com
|
// - API key (sk-...) → cloud API on api.xiaomimimo.com
|
||||||
// - Desktop account/OAuth → same cloud host, plus the Desktop-exclusive Preview
|
// - Desktop account/OAuth → same cloud host, plus the dual-route v2.6 models
|
||||||
// models served by the account-service route on mimo-server-cn.xiaomimimo.com
|
// served by the account-service route (mimo-server-<cluster>.xiaomimimo.com),
|
||||||
// (authorized by a Xiaomi account session cookie, not the key).
|
// 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.
|
// Endpoint is picked per model in the executor, same as opencode-go's /responses split.
|
||||||
export default {
|
export default {
|
||||||
id: "xiaomi-mimo",
|
id: "xiaomi-mimo",
|
||||||
@@ -30,6 +30,16 @@ export default {
|
|||||||
category: "oauth",
|
category: "oauth",
|
||||||
authModes: ["oauth", "apikey"],
|
authModes: ["oauth", "apikey"],
|
||||||
hasOAuth: true,
|
hasOAuth: true,
|
||||||
|
// Keys are cluster-specific. MiMo Desktop declares five regions
|
||||||
|
// (CN/SGP/AMS/RU/IN) — host + sid follow mimo-server-<code> / mimo<code>.
|
||||||
|
regions: [
|
||||||
|
{ id: "cn", label: "China (中国大陆)" },
|
||||||
|
{ id: "sgp", label: "Singapore (新加坡)" },
|
||||||
|
{ id: "ams", label: "Europe · Amsterdam (欧洲)" },
|
||||||
|
{ id: "ru", label: "Russia (俄罗斯)" },
|
||||||
|
{ id: "in", label: "India (印度)" },
|
||||||
|
],
|
||||||
|
defaultRegion: "sgp",
|
||||||
serviceKinds: ["llm", "tts"],
|
serviceKinds: ["llm", "tts"],
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
|
||||||
@@ -50,10 +60,10 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
models: [
|
models: [
|
||||||
// Desktop-exclusive — served by the account-service route, which only accepts
|
// Cloud API & Desktop dual-route models (prefers the desktop account quota when available)
|
||||||
// OpenAI format, so supportedFormats pins them to the openai transport.
|
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", upstreamModelId: "xiaomi/mimo-v2.6-pro", supportedFormats: ["openai"] },
|
||||||
{ id: "mimo-x-pro-preview", name: "MiMo-X-Pro-Preview", upstreamModelId: "xiaomi/mimo-x-pro-preview", supportedFormats: ["openai"] },
|
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", upstreamModelId: "xiaomi/mimo-v2.6-flash", supportedFormats: ["openai"] },
|
||||||
{ id: "mimo-x-flash-preview", name: "MiMo-X-Flash-Preview", upstreamModelId: "xiaomi/mimo-x-flash-preview", 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)
|
// Cloud API models (api.xiaomimimo.com/v1)
|
||||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||||
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ import { DEFAULT_RETRY_CONFIG, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtim
|
|||||||
* MediaConfig: { serviceKinds:[...], ttsConfig, sttConfig, embeddingConfig, imageConfig,
|
* MediaConfig: { serviceKinds:[...], ttsConfig, sttConfig, embeddingConfig, imageConfig,
|
||||||
* searchViaChat:{defaultModel,pricingUrl}, hiddenKinds } — each *Config: {baseUrl,authType,authHeader,
|
* searchViaChat:{defaultModel,pricingUrl}, hiddenKinds } — each *Config: {baseUrl,authType,authHeader,
|
||||||
* format,defaultModel,models:[{id,name,dimensions?}]}.
|
* 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.
|
// Shared transport defaults — provider only overrides fields that differ.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function mapStainlessArch() {
|
|||||||
|
|
||||||
// Anthropic API version (single source — reused across claude-format providers/executors)
|
// Anthropic API version (single source — reused across claude-format providers/executors)
|
||||||
export const ANTHROPIC_API_VERSION = "2023-06-01";
|
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)
|
// Shared Claude-compatible API headers (reused across claude-format providers)
|
||||||
export const CLAUDE_API_HEADERS = {
|
export const CLAUDE_API_HEADERS = {
|
||||||
|
|||||||
@@ -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-terra*", levels: [...CODEX_GPT_5_6_LEVELS, "ultra"] },
|
||||||
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
|
{ provider: "codex", pattern: "*gpt-5.6-luna*", levels: CODEX_GPT_5_6_LEVELS },
|
||||||
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
|
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
|
||||||
|
{ pattern: "*mimo*v2.6*", levels: ["none", "low", "medium", "high", "xhigh"] },
|
||||||
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
|
// DeepSeek v4.* (Alibaba MaaS, probed live): effort low|medium|high|xhigh|max
|
||||||
// all 200 via output_config.effort; "none" is a 400 on the anthropic route
|
// all 200 via output_config.effort; "none" is a 400 on the anthropic route
|
||||||
// (disable thinking instead). none kept for the picker = disable.
|
// (disable thinking instead). none kept for the picker = disable.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// RTK port: compress tool_result content in LLM request bodies
|
// 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 { RAW_CAP, MIN_COMPRESS_SIZE } from "./constants.js";
|
||||||
import { autoDetectFilter } from "./autodetect.js";
|
import { autoDetectFilter } from "./autodetect.js";
|
||||||
import { safeApply } from "./applyFilter.js";
|
import { safeApply } from "./applyFilter.js";
|
||||||
|
|||||||
@@ -12,19 +12,20 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
|||||||
|
|
||||||
const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
||||||
const HARD_CAPS = new Set(CAPABILITY_KEYS);
|
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:
|
// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat:
|
||||||
// accept the legacy array form [{model, enabled}] (treated as enabled, fallback).
|
// accept the legacy array form [{model, enabled}] (treated as enabled, fallback).
|
||||||
function normalizeCapEntry(entry) {
|
function normalizeCapEntry(entry) {
|
||||||
if (Array.isArray(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") {
|
if (entry && typeof entry === "object") {
|
||||||
return {
|
return {
|
||||||
enabled: entry.enabled !== false,
|
enabled: entry.enabled !== false,
|
||||||
roundRobin: !!entry.roundRobin,
|
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: [] };
|
return { enabled: false, roundRobin: false, models: [] };
|
||||||
|
|||||||
@@ -13,9 +13,13 @@
|
|||||||
*
|
*
|
||||||
* PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY
|
* PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY
|
||||||
* requests directly, so we exchange it for a short-lived job token (jt-...)
|
* 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
|
* via the region's jobToken/exchange endpoint (plain JSON POST), then use
|
||||||
* that job token for signing. Job-token traffic must hit api2.qoder.sh —
|
* that job token for signing. On intl, job-token traffic must hit api2.qoder.sh —
|
||||||
* api3 rejects jt- with "Login expired" (403).
|
* 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";
|
import { createHash } from "crypto";
|
||||||
@@ -23,12 +27,12 @@ import { createHash } from "crypto";
|
|||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import { buildCosyHeaders } from "../shared/qoder/cosy.js";
|
import { buildCosyHeaders } from "../shared/qoder/cosy.js";
|
||||||
import {
|
import {
|
||||||
QODER_MODEL_LIST_URL,
|
|
||||||
QODER_CHAT_BASE_ALT,
|
|
||||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
|
||||||
QODER_USERINFO_URL,
|
|
||||||
QODER_IDE_VERSION,
|
QODER_IDE_VERSION,
|
||||||
QODER_CLIENT_TYPE,
|
QODER_CLIENT_TYPE,
|
||||||
|
qoderRegionOf,
|
||||||
|
qoderJobTokenExchangeUrl,
|
||||||
|
qoderUserInfoUrl,
|
||||||
|
qoderInferenceBase,
|
||||||
} from "../shared/qoder/constants.js";
|
} from "../shared/qoder/constants.js";
|
||||||
|
|
||||||
const FETCH_TIMEOUT_MS = 15_000;
|
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-...).
|
* Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
|
||||||
* This endpoint is plain JSON POST — NOT COSY-signed.
|
* 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(
|
const res = await proxyAwareFetch(
|
||||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
qoderJobTokenExchangeUrl(region),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
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).
|
* Resolve the Qoder userId for a job token (needed for COSY signing).
|
||||||
* Returns "" on any failure — callers fall back to the stored userId.
|
* 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 {
|
try {
|
||||||
const res = await proxyAwareFetch(
|
const res = await proxyAwareFetch(
|
||||||
QODER_USERINFO_URL,
|
qoderUserInfoUrl(region),
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
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) {
|
async function resolvePatCredential(pat, proxyOptions = null, signal = null, region = "intl") {
|
||||||
const cached = patJobCache.get(pat);
|
const cacheKey = `${region}:${pat}`;
|
||||||
|
const cached = patJobCache.get(cacheKey);
|
||||||
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached;
|
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached;
|
||||||
|
|
||||||
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
|
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal, region);
|
||||||
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
|
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal, region);
|
||||||
const resolved = { accessToken: jobToken, userId, expiresAt };
|
const resolved = { accessToken: jobToken, userId, expiresAt };
|
||||||
patJobCache.set(pat, resolved);
|
patJobCache.set(cacheKey, resolved);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +147,14 @@ async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
|
|||||||
* Resolve connection credentials to COSY-signable form:
|
* Resolve connection credentials to COSY-signable form:
|
||||||
* - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId
|
* - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId
|
||||||
* - everything else → passed through unchanged
|
* - 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;
|
const raw = credentials?.apiKey || credentials?.accessToken;
|
||||||
if (isQoderPat(raw)) {
|
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 {
|
return {
|
||||||
...credentials,
|
...credentials,
|
||||||
accessToken: resolved.accessToken,
|
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
|
* Stable cache key per credential+region (so different login sessions for the
|
||||||
* account share an entry).
|
* same account share an entry, and the same PAT on both sites stays apart).
|
||||||
*/
|
*/
|
||||||
function cacheKey(credentials) {
|
function cacheKey(credentials) {
|
||||||
const psd = credentials?.providerSpecificData || {};
|
const psd = credentials?.providerSpecificData || {};
|
||||||
const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous";
|
const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous";
|
||||||
return createHash("sha256").update(`qoder:${seed}`).digest("hex");
|
const region = qoderRegionOf(credentials?.provider);
|
||||||
|
return createHash("sha256").update(`qoder:${region}:${seed}`).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,15 +201,13 @@ function cosyCredsFromConnection(credentials) {
|
|||||||
* rawConfigs: Map<modelKey, modelConfigObject> }
|
* rawConfigs: Map<modelKey, modelConfigObject> }
|
||||||
* or `null` on any error.
|
* 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);
|
const creds = cosyCredsFromConnection(credentials);
|
||||||
if (!creds.userId || !creds.authToken) return null;
|
if (!creds.userId || !creds.authToken) return null;
|
||||||
|
|
||||||
// Job-token traffic is rejected by api3 ("Login expired" 403) — the
|
// Intl job-token traffic is rejected by api3 ("Login expired" 403) — the
|
||||||
// official qodercli serves it from api2 instead.
|
// official qodercli serves it from api2 instead; CN uses the single gateway.
|
||||||
const modelListUrl = String(creds.authToken).startsWith("jt-")
|
const modelListUrl = `${qoderInferenceBase(credentials, region)}/algo/api/v2/model/list`;
|
||||||
? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list`
|
|
||||||
: QODER_MODEL_LIST_URL;
|
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@@ -293,14 +300,20 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) {
|
|||||||
* one upstream request per credential.
|
* one upstream request per credential.
|
||||||
*/
|
*/
|
||||||
export async function resolveQoderModels(credentials, options = {}) {
|
export async function resolveQoderModels(credentials, options = {}) {
|
||||||
|
const region = options.region || qoderRegionOf(credentials?.provider);
|
||||||
let resolved;
|
let resolved;
|
||||||
try {
|
try {
|
||||||
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal);
|
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal, region);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`);
|
options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) 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 key = cacheKey(resolved);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -319,7 +332,7 @@ export async function resolveQoderModels(credentials, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fetchPromise = (async () => {
|
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;
|
if (!fetched) return null;
|
||||||
const entry = {
|
const entry = {
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { getKimiUsage } from "./usage/kimi.js";
|
|||||||
import { getDeepseekUsage } from "./usage/deepseek.js";
|
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||||
import { getCommandCodeUsage } from "./usage/commandcode.js";
|
import { getCommandCodeUsage } from "./usage/commandcode.js";
|
||||||
import { getOpenCodeGoUsage } from "./usage/opencode-go.js";
|
import { getOpenCodeGoUsage } from "./usage/opencode-go.js";
|
||||||
|
import { getOpenCodeZenUsage } from "./usage/opencode-zen.js";
|
||||||
import { getGroqUsage } from "./usage/groq.js";
|
import { getGroqUsage } from "./usage/groq.js";
|
||||||
import { getZedUsage } from "./usage/zed.js";
|
import { getZedUsage } from "./usage/zed.js";
|
||||||
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.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 }),
|
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
||||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
||||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
qoder: async (c) => {
|
qoder: (c) => getQoderUsageFor(c),
|
||||||
// PAT (pt-...) connections must be exchanged to a job token before the
|
"qoder-cn": (c) => getQoderUsageFor(c),
|
||||||
// quota endpoint accepts them.
|
|
||||||
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
|
|
||||||
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
|
|
||||||
},
|
|
||||||
iflow: (c) => getIflowUsage(c.accessToken),
|
iflow: (c) => getIflowUsage(c.accessToken),
|
||||||
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
|
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||||
glm: (c) => getGlmUsage(c.apiKey, c.provider, 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),
|
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
|
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
|
||||||
"opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions),
|
"opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions),
|
||||||
|
"opencode-zen": (c) => getOpenCodeZenUsage(c.apiKey, c.proxyOptions),
|
||||||
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||||
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
|
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
|
||||||
groq: (c) => getGroqUsage(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),
|
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 = {}) {
|
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
||||||
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
||||||
const providerDataWithProjectId = {
|
const providerDataWithProjectId = {
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ export function _clearWeeklyCache() {
|
|||||||
weeklyCache.clear();
|
weeklyCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// — Group-name to stable key mapping ——————————————————————
|
// — Group-name and window to stable key mapping ——————————————————————
|
||||||
const GROUP_MATCHERS = [
|
const GROUP_CONFIGS = [
|
||||||
{ pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" },
|
{
|
||||||
{ pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
|
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) {
|
for (const bucket of buckets) {
|
||||||
if (!bucket || typeof bucket !== "object") continue;
|
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();
|
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 (!isWeekly && !isSession) continue;
|
||||||
if (bucket.disabled === true) 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;
|
if (!Number.isFinite(remainingFraction)) continue;
|
||||||
|
|
||||||
// Match group to a known family
|
// Match group to a known family
|
||||||
for (const matcher of GROUP_MATCHERS) {
|
for (const config of GROUP_CONFIGS) {
|
||||||
if (matcher.pattern.test(displayName)) {
|
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 total = 1000;
|
||||||
const remaining = Math.round(total * remainingFraction);
|
const remaining = Math.round(total * remainingFraction);
|
||||||
const used = Math.max(0, total - remaining);
|
const used = Math.max(0, total - remaining);
|
||||||
|
|
||||||
result[matcher.key] = {
|
result[target.key] = {
|
||||||
used,
|
used,
|
||||||
total,
|
total,
|
||||||
resetAt: parseResetTime(bucket.resetTime),
|
resetAt: parseResetTime(bucket.resetTime),
|
||||||
remainingPercentage: remainingFraction * 100,
|
remainingPercentage: remainingFraction * 100,
|
||||||
unlimited: false,
|
unlimited: false,
|
||||||
displayName: matcher.displayName,
|
displayName: target.displayName,
|
||||||
};
|
};
|
||||||
break; // first matching bucket per family wins
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,39 +228,37 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
|||||||
proxyOptions
|
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)
|
// If every model in a family is locked/exhausted (remainingPercentage === 0)
|
||||||
// until a future reset time, the weekly limit cannot be 100% available.
|
// until a future reset time, update the 5h session row (not the weekly row).
|
||||||
// On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports
|
|
||||||
// remainingFraction: 1 even after the starter quota is depleted and all models 429.
|
|
||||||
const entries = Object.entries(quotas);
|
const entries = Object.entries(quotas);
|
||||||
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
||||||
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
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);
|
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]) =>
|
const maxResetAt = geminiModels.reduce((max, [, q]) =>
|
||||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
||||||
);
|
);
|
||||||
weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total;
|
weeklyQuotas.gemini_session.used = weeklyQuotas.gemini_session.total;
|
||||||
weeklyQuotas.gemini_weekly.remainingPercentage = 0;
|
weeklyQuotas.gemini_session.remainingPercentage = 0;
|
||||||
if (maxResetAt) {
|
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);
|
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]) =>
|
const maxResetAt = claudeModels.reduce((max, [, q]) =>
|
||||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
!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_session.used = weeklyQuotas.claude_gpt_session.total;
|
||||||
weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0;
|
weeklyQuotas.claude_gpt_session.remainingPercentage = 0;
|
||||||
if (maxResetAt) {
|
if (maxResetAt) {
|
||||||
weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt;
|
weeklyQuotas.claude_gpt_session.resetAt = maxResetAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* Ollama Cloud Usage
|
||||||
* GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1
|
* GET https://ollama.com/api/usage — `limits.<window>.usage` is a 0..1 ratio
|
||||||
* ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed.
|
* (1.0 = limit reached). Paid plans report session (5h) + weekly (7d); the
|
||||||
* POST https://ollama.com/api/me — plan label (fail-open).
|
* free plan reports a single monthly window. No reset timestamp exposed;
|
||||||
|
* the free monthly reset is derived from the account's signup date.
|
||||||
|
* POST https://ollama.com/api/me — plan label + CreatedAt (fail-open).
|
||||||
* Auth: Authorization: Bearer <apiKey>
|
* Auth: Authorization: Bearer <apiKey>
|
||||||
*/
|
*/
|
||||||
export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) {
|
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 };
|
return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionRaw = limits.session?.usage;
|
const monthlyResetAt = planRaw.toLowerCase() === "free" && me?.CreatedAt
|
||||||
const weeklyRaw = limits.weekly?.usage;
|
? nextMonthlyResetFromSignup(me.CreatedAt)
|
||||||
const sessionNum = Number(sessionRaw);
|
: null;
|
||||||
const weeklyNum = Number(weeklyRaw);
|
|
||||||
const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum);
|
|
||||||
const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum);
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
plan,
|
plan,
|
||||||
message: "Ollama Cloud connected. No usage limits reported.",
|
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 };
|
return { plan, quotas };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { message: `Ollama Cloud error: ${error.message}` };
|
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) {
|
if (!accessToken) {
|
||||||
return { message: "Qoder usage unavailable: no access token" };
|
return { message: "Qoder usage unavailable: no access token" };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await proxyAwareFetch(
|
const response = await proxyAwareFetch(
|
||||||
U("qoder").url,
|
U(providerId).url,
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
107
open-sse/services/usage/opencode-zen.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* OpenCode Zen usage — GET https://opencode.ai/zen/v1/usage
|
||||||
|
* Auth: Bearer <apiKey>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||||
|
import { parseResetTime, toFiniteNumber, U } from "./shared.js";
|
||||||
|
|
||||||
|
const USAGE_URL = U("opencode-zen").url;
|
||||||
|
const QUOTA_NAMES = {
|
||||||
|
rolling: "Rolling",
|
||||||
|
weekly: "Weekly",
|
||||||
|
monthly: "Monthly",
|
||||||
|
};
|
||||||
|
|
||||||
|
function parsePercent(value) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenCodeZenUsage(apiKey = null, proxyOptions = null) {
|
||||||
|
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||||
|
return {
|
||||||
|
message: "OpenCode Zen API key not available. Add a key to view usage.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await proxyAwareFetch(
|
||||||
|
USAGE_URL,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey.trim()}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
return {
|
||||||
|
plan: "OpenCode Zen",
|
||||||
|
message: "OpenCode Zen authentication failed. Check the API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
const error = await response.json().catch(() => null);
|
||||||
|
const subscriptionRequired = error?.error?.type === "EntitlementError";
|
||||||
|
return {
|
||||||
|
plan: "OpenCode Zen",
|
||||||
|
message: subscriptionRequired
|
||||||
|
? "OpenCode Zen billing required for this API key."
|
||||||
|
: "OpenCode Zen access forbidden for this API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
plan: "OpenCode Zen",
|
||||||
|
message: `OpenCode Zen usage API error (${response.status}).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!data?.usage || typeof data.usage !== "object") {
|
||||||
|
return {
|
||||||
|
plan: "OpenCode Zen",
|
||||||
|
message: "OpenCode Zen usage response did not contain quota data.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotas = {};
|
||||||
|
for (const [period, name] of Object.entries(QUOTA_NAMES)) {
|
||||||
|
const quota = data.usage[period];
|
||||||
|
if (!quota || typeof quota !== "object") continue;
|
||||||
|
const percent = parsePercent(quota.percent);
|
||||||
|
if (percent === null) continue;
|
||||||
|
const used = Math.max(0, Math.min(100, toFiniteNumber(percent, 0)));
|
||||||
|
quotas[name] = {
|
||||||
|
used,
|
||||||
|
total: 100,
|
||||||
|
remaining: 100 - used,
|
||||||
|
remainingPercentage: 100 - used,
|
||||||
|
resetAt: parseResetTime(quota.resetsAt),
|
||||||
|
unlimited: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (Object.keys(quotas).length === 0) {
|
||||||
|
return {
|
||||||
|
plan: "OpenCode Zen",
|
||||||
|
message: "OpenCode Zen usage response did not contain valid quota data.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plan: "OpenCode Zen", quotas };
|
||||||
|
} catch (error) {
|
||||||
|
return { message: `OpenCode Zen error: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,20 +8,42 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
|||||||
* Xiaomi MiMo account-session helpers (used for weekly quota).
|
* Xiaomi MiMo account-session helpers (used for weekly quota).
|
||||||
*
|
*
|
||||||
* The weekly quota endpoint lives on the account service domain and is authorized
|
* 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
|
* by an account session cookie, NOT the sk- API key. Acquiring that cookie is a
|
||||||
* MiMo Desktop: a passToken (persisted in Desktop's cookie store) is exchanged via
|
* 1:1 port of MiMo Desktop's ServiceTokenManager (app.asar) — the GOLD STANDARD:
|
||||||
* the passportapi SSO, then authorized for the `mimopc` service, and finally stamped
|
|
||||||
* by the mimo-server /api/sts callback into a `serviceToken` cookie.
|
|
||||||
*
|
*
|
||||||
* Flow (verified against MiMo Desktop traffic):
|
* getServiceToken(sid) / refreshServiceToken(sid):
|
||||||
* 1. GET {api}/api/user/xiaomi/me -> 302 to account SSO (sid=mimopc)
|
* PHASE 1: GET https://account.xiaomi.com/pass/serviceLogin
|
||||||
* 2. GET account /pass/serviceLogin?sid=passportapi&_json=true -> nonce/ssecurity
|
* ?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
|
||||||
* 3. GET {location}&clientSign=... -> account-level serviceToken
|
* Cookie: {userId, passToken, cUserId}
|
||||||
* 4. GET account /pass/serviceLogin?sid=mimopc&callback=<sts>&_json=true
|
* -> {code, location, ssecurity, nonce, bSecondValidation, notificationUrl}
|
||||||
* 5. GET {api}/api/sts?...&ticket... -> Set-Cookie: serviceToken (mimopc scope)
|
* -> code !== 0 is an error (never silent)
|
||||||
|
* PHASE 2: GET {location}&clientSign=sha1(nonce & ssecurity), follow the
|
||||||
|
* redirect chain absorbing Set-Cookie -> serviceToken
|
||||||
|
*
|
||||||
|
* sid is per-cluster (SID_BY_REGION): CN = mimopc, SGP = mimosgp.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE = "https://mimo-server-cn.xiaomimimo.com";
|
// Account-service cluster hosts. MiMo Desktop declares five regions
|
||||||
|
// (rn = {CN, SGP, RU, IN, EU}); the EU cluster is deployed in Amsterdam.
|
||||||
|
// Host + sid naming is unified: mimo-server-<code> / sid = mimo<code>
|
||||||
|
// (ams is the only non-country code). Verified live via /api/user/xiaomi/me.
|
||||||
|
const API_BASE_BY_REGION = {
|
||||||
|
cn: "https://mimo-server-cn.xiaomimimo.com",
|
||||||
|
sgp: "https://mimo-server-sgp.xiaomimimo.com",
|
||||||
|
ams: "https://mimo-server-ams.xiaomimimo.com",
|
||||||
|
ru: "https://mimo-server-ru.xiaomimimo.com",
|
||||||
|
in: "https://mimo-server-in.xiaomimimo.com",
|
||||||
|
};
|
||||||
|
const DEFAULT_API_BASE = API_BASE_BY_REGION.sgp;
|
||||||
|
|
||||||
|
// Cluster service sid — 1:1 with the host code: mimo<code>.
|
||||||
|
// Unknown/absent region falls back to SGP (the international/open cluster).
|
||||||
|
const SID_BY_REGION = { cn: "mimopc", sgp: "mimosgp", ams: "mimoams", ru: "mimoru", in: "mimoin" };
|
||||||
|
function sidForRegion(region) {
|
||||||
|
const r = String(region || "").toLowerCase();
|
||||||
|
return SID_BY_REGION[r] || SID_BY_REGION.sgp;
|
||||||
|
}
|
||||||
|
const API_BASE = DEFAULT_API_BASE;
|
||||||
const ACCOUNT_HOST = "account.xiaomi.com";
|
const ACCOUNT_HOST = "account.xiaomi.com";
|
||||||
const API_UA =
|
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";
|
"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("; ");
|
.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.
|
* Exchange a passToken for a mimo-server service session cookie.
|
||||||
|
* Primary path mirrors the Desktop ServiceTokenManager (app.asar):
|
||||||
|
* PHASE 1: GET /pass/serviceLogin?_locale=zh_CN&_snsNone=true&sid=<clusterSid>&_json=true
|
||||||
|
* Cookie {userId,passToken,cUserId} -> {code,location,ssecurity,nonce}
|
||||||
|
* PHASE 2: GET {location}&clientSign=sha1(nonce&ssecurity), follow the chain
|
||||||
|
* (manual, absorbing Set-Cookie) -> serviceToken
|
||||||
|
* sid is per-cluster (SID_BY_REGION): cn=mimopc, sgp=mimosgp, ams=mimoams, ru=mimoru, in=mimoin.
|
||||||
* @returns {Promise<string|null>} Cookie header value, or null on failure.
|
* @returns {Promise<string|null>} Cookie header value, or null on failure.
|
||||||
*/
|
*/
|
||||||
async function acquireServiceCookie(passJar, proxyOptions) {
|
async function acquireServiceCookie(passJar, proxyOptions, apiBase = DEFAULT_API_BASE, region = "sgp") {
|
||||||
|
const r = String(region || "").toLowerCase();
|
||||||
|
// Hard constraint: CN is ALWAYS direct (ignores proxy even if set)
|
||||||
|
const effectiveProxy = r === "cn" ? null : proxyOptions;
|
||||||
|
const sid = sidForRegion(r);
|
||||||
|
const viaDesktop = await acquireViaDesktopPhases(passJar, effectiveProxy, apiBase, sid);
|
||||||
|
if (viaDesktop) console.log(`[mimoAccount] desktop 2-phase OK (sid=${sid})`);
|
||||||
|
return viaDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acquireViaDesktopPhases(passJar, proxyOptions, apiBase, sid) {
|
||||||
|
const failLog = (reason) => console.log(`[mimoAccount] desktopPhase fail: ${reason}`);
|
||||||
const jar = { ...passJar };
|
const jar = { ...passJar };
|
||||||
const ck = () => cookieHeader(jar);
|
|
||||||
|
|
||||||
// 1. Unauthenticated API call -> 302 carrying the sts callback (sid=mimopc)
|
// PHASE 1 — single serviceLogin call with the TARGET sid (no passportapi
|
||||||
const r1 = await proxyAwareFetch(
|
// prelude; ssecurity/nonce come straight from this response).
|
||||||
`${API_BASE}/api/user/xiaomi/me`,
|
// Desktop only sends: userId, passToken, cUserId (no extra cookies)
|
||||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
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,
|
proxyOptions,
|
||||||
);
|
);
|
||||||
const redirect = r1.headers.get("location");
|
const raw = await p1.text();
|
||||||
if (!redirect) return null;
|
const clean = raw.replace(/^&&&START&&&/, "");
|
||||||
const stsCallback = new URL(redirect).searchParams.get("callback");
|
// Nonce > 2^53 loses precision in JSON.parse — extract raw literal for signing
|
||||||
if (!stsCallback) return null;
|
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
|
if (!j || typeof j.code !== "number" || j.code !== 0 || !j.location || !j.nonce || !j.ssecurity) {
|
||||||
const sso1 = await proxyAwareFetch(
|
failLog(
|
||||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=passportapi&_json=true`,
|
`phase1 sid=${sid} http=${p1.status} code=${j?.code ?? "?"} hasLoc=${!!j?.location}`
|
||||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
+ ` secondValidation=${j?.bSecondValidation ?? "?"} notificationUrl=${j?.notificationUrl ? "present" : "no"}`
|
||||||
proxyOptions,
|
+ ` body=${JSON.stringify(raw.slice(0, 200))}`,
|
||||||
);
|
);
|
||||||
const j1 = JSON.parse((await sso1.text()).replace(/^&&&START&&&/, ""));
|
return null;
|
||||||
const nonce = j1.nonce || (j1.location ? new URL(j1.location).searchParams.get("nonce") : null);
|
}
|
||||||
if (!nonce || !j1.location) return null;
|
absorbSetCookie(jar, p1);
|
||||||
|
|
||||||
// 3. passportapi SSO phase 2 -> account-level serviceToken
|
// PHASE 2 — clientSign the redirect, follow the redirect chain server-side.
|
||||||
const sso2 = await proxyAwareFetch(
|
// ⚠️ CRITICAL DESKTOP SPEC (app.asar / SSO_curl.cpp line 728: cookies.clear()):
|
||||||
`${j1.location}&clientSign=${signatureClientSign(nonce, j1.ssecurity)}`,
|
// Phase 2 MUST NOT send ANY Cookie header! The server returns 200 OK with Set-Cookie: serviceToken!
|
||||||
{ redirect: "manual", headers: { Cookie: ck(), "User-Agent": SSO_UA } },
|
const sep = j.location.includes("?") ? "&" : "?";
|
||||||
proxyOptions,
|
let current = `${j.location}${sep}clientSign=${signatureClientSign(rawNonce || j.nonce, j.ssecurity)}`;
|
||||||
);
|
|
||||||
absorbSetCookie(jar, sso2);
|
|
||||||
|
|
||||||
// 4. mimopc SSO -> sts callback carrying a ticket
|
for (let hop = 0; hop < 8; hop++) {
|
||||||
const sso3 = await proxyAwareFetch(
|
const res = await proxyAwareFetch(
|
||||||
`https://${ACCOUNT_HOST}/pass/serviceLogin?sid=mimopc&callback=${encodeURIComponent(stsCallback)}&_json=true`,
|
current,
|
||||||
{ headers: { Cookie: ck(), "User-Agent": SSO_UA, Accept: "application/json" } },
|
{ redirect: "manual", headers: { "User-Agent": SSO_UA } },
|
||||||
proxyOptions,
|
proxyOptions,
|
||||||
);
|
);
|
||||||
const j3 = JSON.parse((await sso3.text()).replace(/^&&&START&&&/, ""));
|
absorbSetCookie(jar, res);
|
||||||
absorbSetCookie(jar, sso3);
|
const loc = res.headers.get("location");
|
||||||
if (!j3?.location || !/\/api\/sts/.test(j3.location)) return null;
|
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 sidKey = `${sid}_serviceToken`;
|
||||||
const sts = await proxyAwareFetch(
|
if (!jar.serviceToken && jar[sidKey]) {
|
||||||
j3.location,
|
jar.serviceToken = jar[sidKey];
|
||||||
{ redirect: "manual", headers: { "User-Agent": API_UA, Cookie: ck() } },
|
}
|
||||||
proxyOptions,
|
|
||||||
);
|
|
||||||
absorbSetCookie(jar, sts);
|
|
||||||
|
|
||||||
const needed = ["serviceToken", "mimopc_ph", "mimopc_slh", "userId"];
|
if (!jar.serviceToken) {
|
||||||
if (!jar.serviceToken) return null;
|
failLog(`phase2 no serviceToken sid=${sid} jar=[${Object.keys(jar).join(",")}]`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const out = {};
|
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);
|
return cookieHeader(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,13 +242,15 @@ async function acquireServiceCookie(passJar, proxyOptions) {
|
|||||||
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
|
* @param {object|null} providerSpecificData - may carry `mimoPassToken` override
|
||||||
*/
|
*/
|
||||||
async function getServiceCookie(providerSpecificData, proxyOptions) {
|
async function getServiceCookie(providerSpecificData, proxyOptions) {
|
||||||
|
const apiBase = resolveMimoServerBase(providerSpecificData);
|
||||||
const passJar = providerSpecificData?.mimoPassToken
|
const passJar = providerSpecificData?.mimoPassToken
|
||||||
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
|
? { passToken: providerSpecificData.mimoPassToken, userId: providerSpecificData.mimoUserId, cUserId: providerSpecificData.mimoCUserId }
|
||||||
: await readDesktopAccountCookies();
|
: await readDesktopAccountCookies();
|
||||||
if (!passJar) return { cookie: null, reason: "no-pass-token" };
|
if (!passJar) return { cookie: null, reason: "no-pass-token" };
|
||||||
|
|
||||||
// One cached session per passToken — accounts/connections rotate independently.
|
// One cached session per passToken+cluster — accounts/connections rotate
|
||||||
const key = crypto.createHash("sha256").update(passJar.passToken).digest("hex");
|
// 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);
|
const cached = _cache.get(key);
|
||||||
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
|
if (cached && Date.now() - cached.at < COOKIE_TTL_MS) {
|
||||||
@@ -202,8 +267,9 @@ async function getServiceCookie(providerSpecificData, proxyOptions) {
|
|||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
return await acquireServiceCookie(passJar, proxyOptions);
|
return await acquireServiceCookie(passJar, proxyOptions, apiBase, providerSpecificData?.region);
|
||||||
} catch {
|
} 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
|
return null; // network/parse failure — callers degrade, never throw
|
||||||
} finally {
|
} finally {
|
||||||
_inflight.delete(key);
|
_inflight.delete(key);
|
||||||
@@ -234,7 +300,8 @@ export async function getMimoAccountCookie(providerSpecificData = null, proxyOpt
|
|||||||
try {
|
try {
|
||||||
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
|
const { cookie } = await getServiceCookie(providerSpecificData, proxyOptions);
|
||||||
return cookie;
|
return cookie;
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.log(`[mimoAccount] getMimoAccountCookie threw: ${e?.message || e} | ${String(e?.stack || "").split("\n").slice(1, 4).join(" <- ")}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +317,7 @@ export async function getMimoAccountUsage(providerSpecificData = null, proxyOpti
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await proxyAwareFetch(
|
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) },
|
{ headers: { "User-Agent": API_UA, Cookie: cookie, Accept: "application/json" }, signal: AbortSignal.timeout(10000) },
|
||||||
proxyOptions,
|
proxyOptions,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,106 @@
|
|||||||
/**
|
/**
|
||||||
* Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch.
|
* Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch.
|
||||||
*
|
*
|
||||||
* Endpoint set:
|
* Qoder runs two regional sites with parallel endpoint shapes:
|
||||||
* openapi.qoder.sh - device flow + userinfo + quota usage
|
* intl (qoder) CN (qoder-cn)
|
||||||
* center.qoder.sh - token refresh (best-effort, currently 403 for device tokens)
|
* openapi.qoder.sh openapi.qoder.com.cn - device flow + userinfo + quota usage
|
||||||
* api3.qoder.sh - inference (chat) + model list, requires COSY signing
|
* center.qoder.sh gateway.qoder.com.cn - token refresh (best-effort, 403 for device tokens)
|
||||||
* qoder.com/device - browser landing page for device authorization
|
* 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_REGION_INTL = "intl";
|
||||||
export const QODER_CENTER_BASE = "https://center.qoder.sh";
|
export const QODER_REGION_CN = "cn";
|
||||||
export const QODER_CHAT_BASE = "https://api3.qoder.sh";
|
|
||||||
|
// 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);
|
// Job-token (jt-...) traffic is rejected by api3 with "Login expired" (403);
|
||||||
// the official qodercli serves it from api2 instead.
|
// the official qodercli serves it from api2 instead (intl only).
|
||||||
export const QODER_CHAT_BASE_ALT = "https://api2.qoder.sh";
|
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
|
// Device flow endpoints (region-aware variants; these are the intl defaults)
|
||||||
export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`;
|
export function qoderOpenApiBase(region) {
|
||||||
export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
|
return qoderRegionBases(region).openApi;
|
||||||
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`;
|
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.
|
// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange.
|
||||||
// PATs cannot sign COSY requests directly — they must be exchanged first.
|
// PATs cannot sign COSY requests directly — they must be exchanged first.
|
||||||
// This endpoint is NOT COSY-signed (plain JSON POST).
|
// 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_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 = `${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_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")
|
// Official qodercli uploads images here (COSY-signed PUT multipart, field "file")
|
||||||
// instead of inlining base64 into agent_chat_generation.
|
// instead of inlining base64 into agent_chat_generation.
|
||||||
export const QODER_IMAGE_UPLOAD_SIG_PATH = "/api/v2/image/upload";
|
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-...)
|
* "Login expired" (403). Device tokens (dt-...) stay on api3. PATs (pt-...)
|
||||||
* are exchanged for jt- before this is consulted.
|
* 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;
|
const raw = credentials?.apiKey || credentials?.accessToken;
|
||||||
if (
|
if (
|
||||||
typeof raw === "string" &&
|
typeof raw === "string" &&
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export function toOpenAIFinish(reason, format) {
|
|||||||
case CLAUDE_STOP.MAX_TOKENS: return OPENAI_FINISH.LENGTH;
|
case CLAUDE_STOP.MAX_TOKENS: return OPENAI_FINISH.LENGTH;
|
||||||
case CLAUDE_STOP.TOOL_USE: return OPENAI_FINISH.TOOL_CALLS;
|
case CLAUDE_STOP.TOOL_USE: return OPENAI_FINISH.TOOL_CALLS;
|
||||||
case CLAUDE_STOP.STOP_SEQUENCE: return OPENAI_FINISH.STOP;
|
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;
|
default: return OPENAI_FINISH.STOP;
|
||||||
}
|
}
|
||||||
case "commandcode":
|
case "commandcode":
|
||||||
@@ -55,6 +59,7 @@ export function fromOpenAIFinish(reason, format) {
|
|||||||
case OPENAI_FINISH.STOP: return CLAUDE_STOP.END_TURN;
|
case OPENAI_FINISH.STOP: return CLAUDE_STOP.END_TURN;
|
||||||
case OPENAI_FINISH.LENGTH: return CLAUDE_STOP.MAX_TOKENS;
|
case OPENAI_FINISH.LENGTH: return CLAUDE_STOP.MAX_TOKENS;
|
||||||
case OPENAI_FINISH.TOOL_CALLS: return CLAUDE_STOP.TOOL_USE;
|
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: return CLAUDE_STOP.END_TURN;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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"] },
|
{ 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)
|
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
|
||||||
{ provider: "cloudflare-ai", flattenContent: true },
|
{ 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 },
|
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
|
||||||
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
|
// 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),
|
// 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;
|
// "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.
|
// 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 },
|
{ 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.
|
// 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 || []) {
|
for (const key of rule.drop || []) {
|
||||||
if (body[key] !== undefined) delete body[key];
|
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)
|
// CF Workers AI oneOf root schema only accepts content as plain string (#1926)
|
||||||
if (rule.flattenContent && Array.isArray(body.messages)) {
|
if (rule.flattenContent && Array.isArray(body.messages)) {
|
||||||
for (const msg of body.messages) {
|
for (const msg of body.messages) {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function effortToThinkingLevel(effort) {
|
|||||||
|
|
||||||
// Numeric budget → nearest discrete level (reverse map via thresholds).
|
// Numeric budget → nearest discrete level (reverse map via thresholds).
|
||||||
// Returns null when budget <= 0 (no reasoning).
|
// 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) {
|
export function budgetToLevel(budget) {
|
||||||
const b = Number(budget);
|
const b = Number(budget);
|
||||||
if (!b || b <= 0) return null;
|
if (!b || b <= 0) return null;
|
||||||
@@ -41,7 +43,8 @@ export function budgetToLevel(budget) {
|
|||||||
if (b <= 4096) return "low";
|
if (b <= 4096) return "low";
|
||||||
if (b <= 16384) return "medium";
|
if (b <= 16384) return "medium";
|
||||||
if (b <= 28672) return "high";
|
if (b <= 28672) return "high";
|
||||||
return "xhigh";
|
if (b <= 80384) return "xhigh";
|
||||||
|
return "max";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map).
|
// Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map).
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FORMATS } from "./formats.js";
|
|||||||
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
|
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
|
||||||
import { prepareClaudeRequest } from "./formats/claude.js";
|
import { prepareClaudeRequest } from "./formats/claude.js";
|
||||||
import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
|
import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
|
||||||
|
import { restoreToolNames } from "../utils/opencodeFingerprint.js";
|
||||||
import { filterToOpenAIFormat } from "./formats/openai.js";
|
import { filterToOpenAIFormat } from "./formats/openai.js";
|
||||||
import { normalizeThinkingConfig } from "../services/provider.js";
|
import { normalizeThinkingConfig } from "../services/provider.js";
|
||||||
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.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
|
// 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.
|
// be decloaked here or the client sees an unknown ("_ide"-suffixed) tool.
|
||||||
if (sourceFormat === targetFormat) {
|
if (sourceFormat === targetFormat) {
|
||||||
return [decloakStreamChunk(chunk, state?.toolNameMap)];
|
return [restoreToolNames(decloakStreamChunk(chunk, state?.toolNameMap), state?.toolNameMap)];
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = [chunk];
|
let results = [chunk];
|
||||||
@@ -179,7 +180,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
|||||||
const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`);
|
const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`);
|
||||||
if (directFn) {
|
if (directFn) {
|
||||||
const converted = directFn(chunk, state);
|
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)
|
// 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
|
// Attach OpenAI intermediate results for logging
|
||||||
if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
|
if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
|
||||||
results._openaiIntermediate = openaiResults;
|
results._openaiIntermediate = openaiResults;
|
||||||
|
|||||||
@@ -279,10 +279,11 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Antigravity specific fields
|
// Antigravity specific fields.
|
||||||
if (isAntigravity) {
|
// NOTE: the official Antigravity client omits `requestType` entirely on the
|
||||||
envelope.requestType = "agent";
|
// agent (chat) path. Sending `requestType: "agent"` triggers a detail-free
|
||||||
} else {
|
// 429 RESOURCE_EXHAUSTED even with quota available.
|
||||||
|
if (!isAntigravity) {
|
||||||
// Keep safetySettings for Gemini CLI
|
// Keep safetySettings for Gemini CLI
|
||||||
envelope.request.safetySettings = geminiCLI.safetySettings;
|
envelope.request.safetySettings = geminiCLI.safetySettings;
|
||||||
}
|
}
|
||||||
@@ -305,7 +306,8 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
|
|||||||
model: model,
|
model: model,
|
||||||
userAgent: "antigravity",
|
userAgent: "antigravity",
|
||||||
requestId: `agent-${generateUUID()}`,
|
requestId: `agent-${generateUUID()}`,
|
||||||
requestType: "agent",
|
// NOTE: official Antigravity client omits `requestType` on the agent (chat)
|
||||||
|
// path — see the note in wrapInCloudCodeEnvelope() above.
|
||||||
request: {
|
request: {
|
||||||
sessionId: toNumericSessionId(credentials?._clientSessionId) || deriveSessionId(credentials?.email || credentials?.connectionId),
|
sessionId: toNumericSessionId(credentials?._clientSessionId) || deriveSessionId(credentials?.email || credentials?.connectionId),
|
||||||
contents: [],
|
contents: [],
|
||||||
|
|||||||
@@ -149,6 +149,13 @@ export function claudeToOpenAIResponse(chunk, state) {
|
|||||||
|
|
||||||
if (chunk.delta?.stop_reason) {
|
if (chunk.delta?.stop_reason) {
|
||||||
state.finishReason = convertStopReason(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);
|
const finalChunk = createChunk(state, {}, state.finishReason);
|
||||||
|
|
||||||
if (state.usage) {
|
if (state.usage) {
|
||||||
|
|||||||
@@ -14,13 +14,47 @@ import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } fro
|
|||||||
* Translate OpenAI chunk to Responses API events
|
* Translate OpenAI chunk to Responses API events
|
||||||
* @returns {Array} Array of events with { event, data } structure
|
* @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) {
|
export function openaiToOpenAIResponsesResponse(chunk, state) {
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
return flushEvents(state);
|
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 [];
|
if (!chunk.choices?.length) return [];
|
||||||
|
|
||||||
const events = [];
|
const events = [];
|
||||||
const nextSeq = () => ++state.seq;
|
const nextSeq = () => ++state.seq;
|
||||||
|
|
||||||
@@ -112,7 +146,19 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
|
|||||||
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
|
for (const i in state.msgItemAdded) closeMessage(state, emit, i);
|
||||||
closeReasoning(state, emit);
|
closeReasoning(state, emit);
|
||||||
for (const i in state.funcCallIds) closeToolCall(state, emit, i);
|
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;
|
return events;
|
||||||
@@ -376,7 +422,8 @@ function sendCompleted(state, emit) {
|
|||||||
created_at: state.created,
|
created_at: state.created,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
background: false,
|
background: false,
|
||||||
error: null
|
error: null,
|
||||||
|
...(state.responsesUsage ? { usage: state.responsesUsage } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const CLAUDE_STOP = {
|
|||||||
MAX_TOKENS: "max_tokens",
|
MAX_TOKENS: "max_tokens",
|
||||||
TOOL_USE: "tool_use",
|
TOOL_USE: "tool_use",
|
||||||
STOP_SEQUENCE: "stop_sequence",
|
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.
|
// Gemini finishReason values.
|
||||||
|
|||||||
@@ -218,6 +218,12 @@ export function encodeField(fieldNum, wireType, value) {
|
|||||||
return concatArrays(tagBytes, lengthBytes, dataBytes);
|
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);
|
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 ====================
|
// ==================== EXPORTS ====================
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -900,5 +1111,13 @@ export default {
|
|||||||
decodeField,
|
decodeField,
|
||||||
decodeMessage,
|
decodeMessage,
|
||||||
parseConnectRPCFrame,
|
parseConnectRPCFrame,
|
||||||
extractTextFromResponse
|
extractTextFromResponse,
|
||||||
|
encodeAgentValue,
|
||||||
|
decodeAgentValue,
|
||||||
|
encodeMcpToolDefinition,
|
||||||
|
encodeMcpTools,
|
||||||
|
decodeMcpArgs,
|
||||||
|
encodeMcpResultSuccess,
|
||||||
|
encodeMcpResultError,
|
||||||
|
encodeMcpResultToolNotFound,
|
||||||
};
|
};
|
||||||
|
|||||||
232
open-sse/utils/opencodeFingerprint.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for the OpenCode Zen free-tier client fingerprint.
|
||||||
|
*
|
||||||
|
* Live upstream probes show that free-tier requests must include the lowercase
|
||||||
|
* file-search quartet (bash/glob/grep/read). Agent clients such as Claude Code
|
||||||
|
* may declare the same tools with different casing, so those case variants must
|
||||||
|
* be renamed instead of duplicated. The response side restores the caller's
|
||||||
|
* original spelling so downstream clients still recognise their own tool calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Canonical names required by the upstream free-tier gate. */
|
||||||
|
export const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"];
|
||||||
|
|
||||||
|
// Request body -> names renamed for that request. transformRequest() mutates the
|
||||||
|
// same body object that chatCore passed into the executor, so a WeakMap keeps the
|
||||||
|
// mapping request-local without putting transport metadata on the wire.
|
||||||
|
const renamedToolNames = new WeakMap();
|
||||||
|
|
||||||
|
/** Canonical lowercase name when `name` is a quartet member; "" otherwise. */
|
||||||
|
export function fingerprintToolKey(name) {
|
||||||
|
const lower = String(name ?? "").trim().toLowerCase();
|
||||||
|
return OPENCODE_FINGERPRINT_TOOLS.includes(lower) ? lower : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a tool name from either flat ({name}) or chat ({function:{name}}) shape. */
|
||||||
|
function toolNameOf(tool) {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
|
||||||
|
if (typeof tool.name === "string" && tool.name.trim()) return tool.name.trim();
|
||||||
|
const fn = tool.function;
|
||||||
|
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") {
|
||||||
|
return fn.name.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalise only the fingerprint quartet and remove duplicate quartet
|
||||||
|
* variants. Non-fingerprint tools are preserved verbatim, including tools whose
|
||||||
|
* names differ only by case; they are outside OpenCode's fingerprint contract.
|
||||||
|
*
|
||||||
|
* @param {Array} tools
|
||||||
|
* @returns {{ tools: Array, map: Map<string,string> }} map: sent name -> original name
|
||||||
|
*/
|
||||||
|
export function concealFingerprintToolNames(tools) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!Array.isArray(tools) || tools.length === 0) return { tools, map };
|
||||||
|
|
||||||
|
const seenQuartet = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
||||||
|
out.push(tool);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = toolNameOf(tool);
|
||||||
|
const key = fingerprintToolKey(current);
|
||||||
|
if (!key) {
|
||||||
|
out.push(tool);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Bash` + `bash` is rejected upstream as a duplicate. Keep exactly one
|
||||||
|
// declaration for each quartet member.
|
||||||
|
if (seenQuartet.has(key)) continue;
|
||||||
|
seenQuartet.add(key);
|
||||||
|
|
||||||
|
if (current !== key) {
|
||||||
|
map.set(key, current);
|
||||||
|
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function)
|
||||||
|
? tool.function
|
||||||
|
: null;
|
||||||
|
out.push(fn ? { ...tool, function: { ...fn, name: key } } : { ...tool, name: key });
|
||||||
|
} else {
|
||||||
|
out.push(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tools: out, map };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append only genuinely missing quartet declarations. */
|
||||||
|
export function appendMissingFingerprintTools(tools, flat) {
|
||||||
|
const list = Array.isArray(tools) ? tools : [];
|
||||||
|
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
||||||
|
if (list.some((tool) => fingerprintToolKey(toolNameOf(tool)) === name)) continue;
|
||||||
|
list.push(flat ? {
|
||||||
|
type: "function",
|
||||||
|
name,
|
||||||
|
description: "This tool is currently unavailable and must not be used.",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
} : {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description: "This tool is currently unavailable and must not be used.",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point an explicit tool_choice at a quartet member after canonicalisation. */
|
||||||
|
export function retargetToolChoice(body, map) {
|
||||||
|
if (!body || typeof body !== "object" || !map?.size) return;
|
||||||
|
const choice = body.tool_choice;
|
||||||
|
if (!choice || typeof choice !== "object" || Array.isArray(choice)) return;
|
||||||
|
|
||||||
|
if (typeof choice.name === "string") {
|
||||||
|
const key = fingerprintToolKey(choice.name);
|
||||||
|
if (key && map.has(key)) body.tool_choice = { ...choice, name: key };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = choice.function;
|
||||||
|
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") {
|
||||||
|
const key = fingerprintToolKey(fn.name);
|
||||||
|
if (key && map.has(key)) {
|
||||||
|
body.tool_choice = { ...choice, function: { ...fn, name: key } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full request-side pass: canonicalise quartet case variants, remove duplicate
|
||||||
|
* quartet declarations, append missing members and preserve the legacy
|
||||||
|
* tool_choice defaults used by the OpenCode executor.
|
||||||
|
*
|
||||||
|
* @param {object} body
|
||||||
|
* @param {boolean} flat - true for Responses tools ({name}), false for chat tools
|
||||||
|
* @returns {Map<string,string>} map: sent name -> original name
|
||||||
|
*/
|
||||||
|
export function applyFingerprintTools(body, flat) {
|
||||||
|
if (!body || typeof body !== "object") return new Map();
|
||||||
|
|
||||||
|
const hadClientTools = Array.isArray(body.tools) && body.tools.length > 0;
|
||||||
|
const { tools, map } = concealFingerprintToolNames(body.tools);
|
||||||
|
body.tools = appendMissingFingerprintTools(tools, flat);
|
||||||
|
retargetToolChoice(body, map);
|
||||||
|
|
||||||
|
// Preserve the existing executor semantics. Responses uses auto when the
|
||||||
|
// fingerprint helper supplies tools; chat requests with no caller tools use
|
||||||
|
// none so the injected decoys cannot be selected.
|
||||||
|
if (!body.tool_choice) {
|
||||||
|
if (flat) body.tool_choice = "auto";
|
||||||
|
else if (!hadClientTools) body.tool_choice = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRenamedToolNames(body, map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store the rename map for `body`. */
|
||||||
|
export function recordRenamedToolNames(body, map) {
|
||||||
|
if (!body || typeof body !== "object" || !map?.size) return;
|
||||||
|
renamedToolNames.set(body, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieve the rename map for `body`. */
|
||||||
|
export function takeRenamedToolNames(body) {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
return renamedToolNames.get(body) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response side -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Restore caller tool spellings in supported response/event shapes. */
|
||||||
|
export function restoreToolNames(payload, map) {
|
||||||
|
if (!map?.size || !payload) return payload;
|
||||||
|
if (Array.isArray(payload)) return payload.map((item) => restoreToolNames(item, map));
|
||||||
|
if (typeof payload !== "object") return payload;
|
||||||
|
|
||||||
|
let out = payload;
|
||||||
|
const put = (key, value) => {
|
||||||
|
if (out === payload) out = { ...payload };
|
||||||
|
out[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Claude streaming content_block_start event.
|
||||||
|
if (payload.type === "content_block_start") {
|
||||||
|
const block = payload.content_block;
|
||||||
|
if (block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)) {
|
||||||
|
put("content_block", { ...block, name: map.get(block.name) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude non-streaming message body.
|
||||||
|
if (Array.isArray(payload.content)) {
|
||||||
|
put("content", payload.content.map((block) =>
|
||||||
|
block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)
|
||||||
|
? { ...block, name: map.get(block.name) }
|
||||||
|
: block));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Chat Completions, both streaming delta and JSON message shapes.
|
||||||
|
if (Array.isArray(payload.choices)) {
|
||||||
|
put("choices", payload.choices.map((choice) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...choice };
|
||||||
|
for (const holder of ["delta", "message"]) {
|
||||||
|
const value = choice?.[holder];
|
||||||
|
if (!value || !Array.isArray(value.tool_calls) || value.tool_calls.length === 0) continue;
|
||||||
|
const calls = value.tool_calls.map((call) => {
|
||||||
|
const name = call?.function?.name;
|
||||||
|
if (typeof name === "string" && map.has(name)) {
|
||||||
|
changed = true;
|
||||||
|
return { ...call, function: { ...call.function, name: map.get(name) } };
|
||||||
|
}
|
||||||
|
return call;
|
||||||
|
});
|
||||||
|
next[holder] = { ...value, tool_calls: calls };
|
||||||
|
}
|
||||||
|
return changed ? next : choice;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Responses final JSON body.
|
||||||
|
if (Array.isArray(payload.output)) {
|
||||||
|
put("output", payload.output.map((item) =>
|
||||||
|
item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)
|
||||||
|
? { ...item, name: map.get(item.name) }
|
||||||
|
: item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Responses SSE events such as response.output_item.added/done.
|
||||||
|
const item = payload.item;
|
||||||
|
if (item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)) {
|
||||||
|
put("item", { ...item, name: map.get(item.name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -215,8 +215,11 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
|||||||
const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl);
|
const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl);
|
||||||
if (vercelRelayUrl) {
|
if (vercelRelayUrl) {
|
||||||
const parsed = new URL(targetUrl);
|
const parsed = new URL(targetUrl);
|
||||||
|
const baseHeaders = options.headers instanceof Headers
|
||||||
|
? Object.fromEntries(options.headers.entries())
|
||||||
|
: { ...(options.headers || {}) };
|
||||||
const relayHeaders = {
|
const relayHeaders = {
|
||||||
...options.headers,
|
...baseHeaders,
|
||||||
"x-relay-target": `${parsed.protocol}//${parsed.host}`,
|
"x-relay-target": `${parsed.protocol}//${parsed.host}`,
|
||||||
"x-relay-path": `${parsed.pathname}${parsed.search}`,
|
"x-relay-path": `${parsed.pathname}${parsed.search}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,13 @@ export function createSSEStream(options = {}) {
|
|||||||
const decoder = new TextDecoder("utf-8", { fatal: false });
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
|
||||||
const state = mode === STREAM_MODE.TRANSLATE
|
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;
|
: null;
|
||||||
|
|
||||||
let totalContentLength = 0;
|
let totalContentLength = 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.5.81",
|
"version": "0.5.86",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。",
|
"⚠️ 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": "✓ 确认添加",
|
"✓ Confirm Add": "✓ 确认添加",
|
||||||
"📝 Configure providers in dashboard or use environment variables": "📝 在仪表盘中配置提供商或使用环境变量",
|
"📝 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": "印度"
|
||||||
}
|
}
|
||||||
|
|||||||
9
public/providers/codewhale.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/providers/crush.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/providers/forge.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/providers/omp.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
6
public/providers/pi.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||||
|
<path fill="#F09082" d="M165.29 165.29H517.36V400H400V282.65H165.29Z"/>
|
||||||
|
<path fill="#4D9ABF" d="M165.29 282.65H282.65V400H400V517.36H282.65V634.72H165.29Z"/>
|
||||||
|
<path fill="#F1BE58" d="M517.36 400H634.72V634.72H517.36Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
BIN
public/providers/qoder-cn.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
101
public/providers/smelt.svg
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 12.1" shape-rendering="crispEdges" role="img" aria-label="smelt logo">
|
||||||
|
<rect x="6" y="0" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="6" y="1.1" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="7" y="1.1" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="5" y="2.2" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="6" y="2.2" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="7" y="2.2" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="8" y="2.2" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="9" y="2.2" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="10" y="2.2" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="4" y="3.3" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="5" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="6" y="3.3" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="7" y="3.3" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="8" y="3.3" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="9" y="3.3" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="10" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="11" y="3.3" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="13" y="3.3" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="14" y="3.3" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="3" y="4.4" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="4" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="5" y="4.4" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="6" y="4.4" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="7" y="4.4" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="8" y="4.4" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="9" y="4.4" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="10" y="4.4" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="11" y="4.4" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="12" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="13" y="4.4" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="14" y="4.4" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="15" y="4.4" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="1" y="5.5" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="2" y="5.5" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="3" y="5.5" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="4" y="5.5" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="5" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="6" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="7" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="8" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="9" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="10" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="11" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="12" y="5.5" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="13" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="14" y="5.5" width="1" height="1.1" fill="#ffd700"/>
|
||||||
|
<rect x="15" y="5.5" width="1" height="1.1" fill="#ff8700"/>
|
||||||
|
<rect x="16" y="5.5" width="1" height="1.1" fill="#ff5f00"/>
|
||||||
|
<rect x="17" y="5.5" width="1" height="1.1" fill="#af0000"/>
|
||||||
|
<rect x="0" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="1" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="2" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="4" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="5" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="6" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="7" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="8" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="10" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="11" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="12" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="14" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="17" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="18" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="19" y="7.7" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="0" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="1" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="2" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="4" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="5" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="6" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="7" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="8" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="10" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="11" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="12" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="14" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="17" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="18" y="8.8" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="19" y="8.8" width="1" height="1.1" fill="#9b8880"/>
|
||||||
|
<rect x="2" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="4" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="6" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="8" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="10" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="14" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="18" y="9.9" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="0" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="1" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="2" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="4" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="6" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="8" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="10" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="11" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="12" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="14" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="15" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="18" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
<rect x="19" y="11" width="1" height="1.1" fill="#fff6ef"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
@@ -9,7 +9,7 @@ import {
|
|||||||
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
|
ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard,
|
||||||
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
|
HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard,
|
||||||
ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
|
ClineToolCard, KiloToolCard, DeepSeekTuiToolCard,
|
||||||
JcodeToolCard, GrokBuildToolCard,
|
JcodeToolCard, GrokBuildToolCard, GenericCliToolCard,
|
||||||
} from "../components";
|
} from "../components";
|
||||||
|
|
||||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||||
@@ -166,6 +166,13 @@ export default function ToolDetailClient({ toolId, machineId }) {
|
|||||||
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
return <JcodeToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||||
case "grok-build":
|
case "grok-build":
|
||||||
return <GrokBuildToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
return <GrokBuildToolCard {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||||
|
case "pi":
|
||||||
|
case "omp":
|
||||||
|
case "crush":
|
||||||
|
case "forge":
|
||||||
|
case "smelt":
|
||||||
|
case "codewhale":
|
||||||
|
return <GenericCliToolCard {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||||
default:
|
default:
|
||||||
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
|
return <DefaultToolCard toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,636 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||||
|
import Image from "next/image";
|
||||||
|
import BaseUrlSelect from "./BaseUrlSelect";
|
||||||
|
import { rememberEndpoint } from "./cliEndpointPresets";
|
||||||
|
import ApiKeySelect from "./ApiKeySelect";
|
||||||
|
import { matchKnownEndpoint } from "./cliEndpointMatch";
|
||||||
|
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||||
|
|
||||||
|
export default function GenericCliToolCard({
|
||||||
|
tool,
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
baseUrl,
|
||||||
|
apiKeys,
|
||||||
|
activeProviders = [],
|
||||||
|
cloudEnabled,
|
||||||
|
initialStatus,
|
||||||
|
tunnelEnabled,
|
||||||
|
tunnelPublicUrl,
|
||||||
|
tailscaleEnabled,
|
||||||
|
tailscaleUrl,
|
||||||
|
}) {
|
||||||
|
const [status, setStatus] = useState(() => initialStatus || null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [message, setMessage] = useState(null);
|
||||||
|
const [showInstallGuide, setShowInstallGuide] = useState(false);
|
||||||
|
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
|
||||||
|
const [selectedModel, setSelectedModel] = useState(() => {
|
||||||
|
const cfg = initialStatus?.config;
|
||||||
|
return cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id || "";
|
||||||
|
});
|
||||||
|
const [selectedModels, setSelectedModels] = useState(() => {
|
||||||
|
const cfg = initialStatus?.config;
|
||||||
|
const list = cfg?.providers?.["9router"]?.models;
|
||||||
|
if (Array.isArray(list) && list.length > 0) {
|
||||||
|
return list.map((m) => (typeof m === "string" ? m : m.id));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [showManualConfigModal, setShowManualConfigModal] = useState(false);
|
||||||
|
const [customBaseUrl, setCustomBaseUrl] = useState("");
|
||||||
|
|
||||||
|
const endpointUrl = `/api/cli-tools/${tool.id}-settings`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
if (isExpanded && !initialStatus) {
|
||||||
|
fetch(endpointUrl)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (active) {
|
||||||
|
setStatus(data);
|
||||||
|
const cfg = data?.config;
|
||||||
|
if (tool.id === "pi") {
|
||||||
|
const list = cfg?.providers?.["9router"]?.models;
|
||||||
|
if (Array.isArray(list) && list.length > 0) {
|
||||||
|
const ids = list.map((m) => (typeof m === "string" ? m : m.id));
|
||||||
|
setSelectedModels(ids);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const mod = cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id;
|
||||||
|
if (mod) setSelectedModel((prev) => prev || mod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (active) setStatus({ installed: false, error: error.message });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [isExpanded, initialStatus, endpointUrl, tool.id]);
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpointUrl);
|
||||||
|
const data = await res.json();
|
||||||
|
setStatus(data);
|
||||||
|
const cfg = data?.config;
|
||||||
|
if (tool.id === "pi") {
|
||||||
|
const list = cfg?.providers?.["9router"]?.models;
|
||||||
|
if (Array.isArray(list) && list.length > 0) {
|
||||||
|
const ids = list.map((m) => (typeof m === "string" ? m : m.id));
|
||||||
|
setSelectedModels(ids);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const mod = cfg?.model || cfg?.openai?.model || cfg?.providers?.["9router"]?.models?.[0]?.id;
|
||||||
|
if (mod && !selectedModel) setSelectedModel(mod);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({ installed: false, error: error.message });
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEffectiveBaseUrl = () => {
|
||||||
|
const url = customBaseUrl || `${baseUrl}/v1`;
|
||||||
|
return url.endsWith("/v1") ? url : `${url}/v1`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentBaseUrl = () => {
|
||||||
|
if (!status?.config) return "";
|
||||||
|
const cfg = status.config;
|
||||||
|
if (typeof cfg.baseUrl === "string") return cfg.baseUrl;
|
||||||
|
if (typeof cfg.openai?.base_url === "string") return cfg.openai.base_url;
|
||||||
|
if (typeof cfg.providers?.["9router"]?.base_url === "string") return cfg.providers["9router"].base_url;
|
||||||
|
if (typeof cfg.providers?.["9router"]?.baseUrl === "string") return cfg.providers["9router"].baseUrl;
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentBaseUrl = getCurrentBaseUrl();
|
||||||
|
|
||||||
|
const getConfigStatus = () => {
|
||||||
|
if (!status?.installed) return null;
|
||||||
|
if (!status.has9Router) return "not_configured";
|
||||||
|
if (currentBaseUrl && matchKnownEndpoint(currentBaseUrl, { tunnelPublicUrl, tailscaleUrl })) {
|
||||||
|
return "configured";
|
||||||
|
}
|
||||||
|
return "configured";
|
||||||
|
};
|
||||||
|
|
||||||
|
const configStatus = getConfigStatus();
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
setApplying(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const keyToUse = (selectedApiKey && selectedApiKey.trim())
|
||||||
|
? selectedApiKey
|
||||||
|
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
baseUrl: getEffectiveBaseUrl(),
|
||||||
|
apiKey: keyToUse,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tool.id === "pi") {
|
||||||
|
payload.models = selectedModels.length > 0 ? selectedModels : ["provider/model-id"];
|
||||||
|
} else {
|
||||||
|
payload.model = selectedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpointUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
rememberEndpoint(getEffectiveBaseUrl());
|
||||||
|
setMessage({ type: "success", text: data.message || "Settings applied successfully!" });
|
||||||
|
await checkStatus();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: data.error?.message || "Failed to apply settings." });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: "error", text: error.message });
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
setRestoring(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(endpointUrl, { method: "DELETE" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage({ type: "success", text: data.message || "Settings removed successfully." });
|
||||||
|
await checkStatus();
|
||||||
|
} else {
|
||||||
|
setMessage({ type: "error", text: data.error?.message || "Failed to reset settings." });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setMessage({ type: "error", text: error.message });
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectModel = (model) => {
|
||||||
|
if (tool.id === "pi") {
|
||||||
|
if (!selectedModels.includes(model.value)) {
|
||||||
|
setSelectedModels((prev) => [...prev, model.value]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedModel(model.value);
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddAllActiveModels = () => {
|
||||||
|
const allModels = [];
|
||||||
|
activeProviders.forEach((conn) => {
|
||||||
|
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
|
||||||
|
const providerModels = getModelsByProviderId(conn.provider);
|
||||||
|
providerModels.forEach((m) => {
|
||||||
|
const val = `${alias}/${m.id}`;
|
||||||
|
if (!allModels.includes(val)) allModels.push(val);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (allModels.length > 0) {
|
||||||
|
setSelectedModels((prev) => Array.from(new Set([...prev, ...allModels])));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveModel = (modelToRemove) => {
|
||||||
|
setSelectedModels((prev) => prev.filter((m) => m !== modelToRemove));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInstallCommand = () => {
|
||||||
|
switch (tool.id) {
|
||||||
|
case "pi":
|
||||||
|
return "curl -fsSL https://pi.dev/install.sh | sh # or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent";
|
||||||
|
case "omp":
|
||||||
|
return "npm install -g oh-my-pi";
|
||||||
|
case "crush":
|
||||||
|
return "brew install charmbracelet/tap/crush # or go install github.com/charmbracelet/crush@latest";
|
||||||
|
case "forge":
|
||||||
|
return "cargo install forgecode";
|
||||||
|
case "smelt":
|
||||||
|
return "cargo install smelt";
|
||||||
|
case "codewhale":
|
||||||
|
return "cargo install codewhale";
|
||||||
|
default:
|
||||||
|
return `npm install -g ${tool.id}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getManualConfigContent = () => {
|
||||||
|
const effectiveUrl = getEffectiveBaseUrl();
|
||||||
|
const key = selectedApiKey || "sk_9router";
|
||||||
|
const mod = selectedModel || "provider/model-id";
|
||||||
|
|
||||||
|
switch (tool.id) {
|
||||||
|
case "pi": {
|
||||||
|
const modelsList = selectedModels.length > 0 ? selectedModels : [mod];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filename: "~/.pi/agent/models.json",
|
||||||
|
content: JSON.stringify(
|
||||||
|
{
|
||||||
|
providers: {
|
||||||
|
"9router": {
|
||||||
|
baseUrl: effectiveUrl,
|
||||||
|
apiKey: key,
|
||||||
|
api: "openai-completions",
|
||||||
|
models: modelsList.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
contextWindow: 128000,
|
||||||
|
maxTokens: 16384,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "omp":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filename: "~/.omp/agent/models.yml",
|
||||||
|
content: `providers:\n 9router:\n baseUrl: ${effectiveUrl}\n apiKey: ${key}\n api: openai-completions\n authHeader: true\n disableStrictTools: true\n discovery:\n type: proxy`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "crush":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filename: "~/.config/crush/crush.json",
|
||||||
|
content: JSON.stringify(
|
||||||
|
{
|
||||||
|
providers: {
|
||||||
|
"9router": {
|
||||||
|
type: "openai-compat",
|
||||||
|
base_url: effectiveUrl,
|
||||||
|
api_key: key,
|
||||||
|
models: [{ id: mod, name: mod, context_window: 128000 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "forge":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filename: "~/.forge/config.toml",
|
||||||
|
content: `# Forge config — managed by 9Router\n\n[openai]\napi_key = "${key}"\nbase_url = "${effectiveUrl}"\nmodel = "${mod}"`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "smelt":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filename: "~/.smelt/config.json",
|
||||||
|
content: JSON.stringify({ baseUrl: effectiveUrl, apiKey: key, model: mod, _managedBy: "9router" }, null, 2),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case "codewhale":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
filename: "~/.codewhale/config.toml",
|
||||||
|
content: `# CodeWhale config — managed by 9Router\n\n[openai]\nbase_url = "${effectiveUrl}"\napi_key = "${key}"\nmodel = "${mod}"`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="xs" className="overflow-hidden">
|
||||||
|
{/* Header clickable */}
|
||||||
|
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="size-8 flex items-center justify-center shrink-0">
|
||||||
|
{tool.image ? (
|
||||||
|
<Image
|
||||||
|
src={tool.image}
|
||||||
|
alt={tool.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="size-8 object-contain rounded-lg"
|
||||||
|
sizes="32px"
|
||||||
|
onError={(e) => { e.target.style.display = "none"; }}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
) : tool.icon ? (
|
||||||
|
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>
|
||||||
|
{tool.icon}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="material-symbols-outlined text-[28px] text-primary">terminal</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||||
|
{configStatus === "configured" && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{configStatus === "not_configured" && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||||
|
Not configured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{configStatus === "other" && (
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||||
|
Other
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||||
|
{checking && (
|
||||||
|
<div className="flex items-center gap-2 text-text-muted">
|
||||||
|
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||||
|
<span>Checking {tool.name}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checking && status && !status.installed && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="material-symbols-outlined text-yellow-500">warning</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium text-yellow-600 dark:text-yellow-400">{tool.name} not detected locally</p>
|
||||||
|
<p className="text-sm text-text-muted">Manual configuration is still available if 9router is deployed on a remote server.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pl-9">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowManualConfigModal(true)}
|
||||||
|
className="!bg-yellow-500/20 !border-yellow-500/40 !text-yellow-700 dark:!text-yellow-300 hover:!bg-yellow-500/30"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px] mr-1">content_copy</span>
|
||||||
|
Manual Config
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
|
||||||
|
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
|
||||||
|
{showInstallGuide ? "Hide" : "How to Install"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showInstallGuide && (
|
||||||
|
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium mb-3">Installation Guide</h4>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-text-muted mb-1">Install command:</p>
|
||||||
|
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">{getInstallCommand()}</code>
|
||||||
|
</div>
|
||||||
|
{tool.docsUrl && (
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Docs: <a href={tool.docsUrl} target="_blank" rel="noreferrer" className="text-primary hover:underline">{tool.docsUrl}</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!checking && status?.installed && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Endpoint (selector) */}
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||||
|
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||||
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
|
<BaseUrlSelect
|
||||||
|
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||||
|
onChange={setCustomBaseUrl}
|
||||||
|
requiresExternalUrl={tool.requiresExternalUrl}
|
||||||
|
tunnelEnabled={tunnelEnabled}
|
||||||
|
tunnelPublicUrl={tunnelPublicUrl}
|
||||||
|
tailscaleEnabled={tailscaleEnabled}
|
||||||
|
tailscaleUrl={tailscaleUrl}
|
||||||
|
currentUrl={currentBaseUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current configured */}
|
||||||
|
{currentBaseUrl ? (
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||||
|
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Current</span>
|
||||||
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
|
<span className="min-w-0 truncate rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||||
|
{currentBaseUrl}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||||
|
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||||
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
|
<ApiKeySelect value={selectedApiKey} onChange={setSelectedApiKey} apiKeys={apiKeys} cloudEnabled={cloudEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Models selector cho Pi (multi-models) */}
|
||||||
|
{tool.id === "pi" && (
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||||
|
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm mt-1">Models</span>
|
||||||
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline mt-1.5">arrow_forward</span>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap gap-1.5 min-h-[36px] p-2 bg-surface rounded border border-border">
|
||||||
|
{selectedModels.length === 0 ? (
|
||||||
|
<span className="text-xs text-text-muted italic">No models selected. Add models to use in Pi.</span>
|
||||||
|
) : (
|
||||||
|
selectedModels.map((modelId) => (
|
||||||
|
<span
|
||||||
|
key={modelId}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-bg-secondary text-xs text-text-main border border-border"
|
||||||
|
>
|
||||||
|
<span>{modelId}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveModel(modelId)}
|
||||||
|
className="text-text-muted hover:text-red-500 rounded p-0.5"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
disabled={!activeProviders?.length}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px] mr-1">add</span>
|
||||||
|
Add Model
|
||||||
|
</Button>
|
||||||
|
{activeProviders?.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleAddAllActiveModels}
|
||||||
|
className="text-xs text-primary hover:text-primary-hover"
|
||||||
|
>
|
||||||
|
+ Add All Active Models
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{selectedModels.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedModels([])}
|
||||||
|
className="text-xs text-text-muted hover:text-red-500 ml-auto"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model (1 model cho các tool khác, trừ omp) */}
|
||||||
|
{tool.id !== "omp" && tool.id !== "pi" && (
|
||||||
|
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||||
|
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Model</span>
|
||||||
|
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||||
|
<div className="relative w-full min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
placeholder="provider/model-id"
|
||||||
|
className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
|
||||||
|
/>
|
||||||
|
{selectedModel && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedModel("")}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 p-0.5 text-text-muted hover:text-red-500 rounded transition-colors"
|
||||||
|
title="Clear"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
disabled={!activeProviders?.length}
|
||||||
|
className={`w-full sm:w-auto rounded border px-2 py-2 text-xs transition-colors sm:py-1.5 whitespace-nowrap sm:shrink-0 ${
|
||||||
|
activeProviders?.length
|
||||||
|
? "bg-surface border-border text-text-main hover:border-primary cursor-pointer"
|
||||||
|
: "opacity-50 cursor-not-allowed border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Select Model
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-lg text-sm ${
|
||||||
|
message.type === "success"
|
||||||
|
? "bg-green-500/10 text-green-600 dark:text-green-400 border border-green-500/20"
|
||||||
|
: "bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={applying || checking}
|
||||||
|
>
|
||||||
|
{applying ? "Applying..." : "Apply Settings"}
|
||||||
|
</Button>
|
||||||
|
{status?.has9Router && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRestore}
|
||||||
|
disabled={restoring || checking}
|
||||||
|
className="text-red-500 hover:text-red-600 hover:border-red-500/50"
|
||||||
|
>
|
||||||
|
{restoring ? "Removing..." : "Remove from Tool"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowManualConfigModal(true)}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px] mr-1">code</span>
|
||||||
|
Manual Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModelSelectModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onSelect={handleSelectModel}
|
||||||
|
activeProviders={activeProviders}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ManualConfigModal
|
||||||
|
isOpen={showManualConfigModal}
|
||||||
|
onClose={() => setShowManualConfigModal(false)}
|
||||||
|
title={`${tool.name} Configuration`}
|
||||||
|
configs={getManualConfigContent()}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export { default as KiloToolCard } from "./KiloToolCard";
|
|||||||
export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
|
export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard";
|
||||||
export { default as JcodeToolCard } from "./JcodeToolCard";
|
export { default as JcodeToolCard } from "./JcodeToolCard";
|
||||||
export { default as GrokBuildToolCard } from "./GrokBuildToolCard";
|
export { default as GrokBuildToolCard } from "./GrokBuildToolCard";
|
||||||
|
export { default as GenericCliToolCard } from "./GenericCliToolCard";
|
||||||
export { default as MitmServerCard } from "./MitmServerCard";
|
export { default as MitmServerCard } from "./MitmServerCard";
|
||||||
export { default as MitmToolCard } from "./MitmToolCard";
|
export { default as MitmToolCard } from "./MitmToolCard";
|
||||||
export { default as MitmLinkCard } from "./MitmLinkCard";
|
export { default as MitmLinkCard } from "./MitmLinkCard";
|
||||||
|
|||||||
@@ -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 { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ModelSelectSidePanel, ConfirmModal, CapacityBadges, Select, Toggle, TagInput } from "@/shared/components";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
import { useModelCaps } from "@/shared/hooks/useModelCaps";
|
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, -, _
|
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||||
const VALID_NAME_REGEX = /^[a-zA-Z0-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
|
// 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.
|
// to the first enabled model here instead of erroring or dropping the data.
|
||||||
const CAPACITY_ADAPTER_CAPS = [
|
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.
|
// 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_CAP_ENTRY = { enabled: true, roundRobin: false, models: [] };
|
||||||
const EMPTY_CAPACITY_ADAPTER = {
|
const EMPTY_CAPACITY_ADAPTER = {
|
||||||
vision: { ...EMPTY_CAP_ENTRY },
|
vision: { ...EMPTY_CAP_ENTRY },
|
||||||
@@ -29,21 +29,29 @@ const EMPTY_CAPACITY_ADAPTER = {
|
|||||||
audioInput: { ...EMPTY_CAP_ENTRY },
|
audioInput: { ...EMPTY_CAP_ENTRY },
|
||||||
videoInput: { ...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}.
|
// Backward-compat: legacy stored form was an array of {model, enabled}.
|
||||||
function normalizeCapEntry(entry) {
|
function normalizeCapEntry(entry) {
|
||||||
if (Array.isArray(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") {
|
if (entry && typeof entry === "object") {
|
||||||
return {
|
return {
|
||||||
enabled: entry.enabled !== false,
|
enabled: entry.enabled !== false,
|
||||||
roundRobin: !!entry.roundRobin,
|
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 };
|
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() {
|
export default function CombosPage() {
|
||||||
const [combos, setCombos] = useState([]);
|
const [combos, setCombos] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -55,6 +63,9 @@ export default function CombosPage() {
|
|||||||
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
|
const [capacityAdapter, setCapacityAdapter] = useState(EMPTY_CAPACITY_ADAPTER);
|
||||||
const { getCaps } = useModelCaps();
|
const { getCaps } = useModelCaps();
|
||||||
const [confirmState, setConfirmState] = useState(null);
|
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();
|
const { copied, copy } = useCopyToClipboard();
|
||||||
// Reorder sensors: small activation distance keeps click-to-edit, drag-to-reorder.
|
// Reorder sensors: small activation distance keeps click-to-edit, drag-to-reorder.
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -69,6 +80,88 @@ export default function CombosPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // 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 () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [combosRes, providersRes, settingsRes] = await Promise.all([
|
const [combosRes, providersRes, settingsRes] = await Promise.all([
|
||||||
@@ -79,7 +172,7 @@ export default function CombosPage() {
|
|||||||
const combosData = await combosRes.json();
|
const combosData = await combosRes.json();
|
||||||
const providersData = await providersRes.json();
|
const providersData = await providersRes.json();
|
||||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||||
|
|
||||||
// Only LLM combos here - webSearch/webFetch combos belong to media-providers/web
|
// 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 (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm"));
|
||||||
if (providersRes.ok) {
|
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 handleDelete = async (id) => {
|
||||||
|
const combo = combos.find((c) => c.id === id);
|
||||||
setConfirmState({
|
setConfirmState({
|
||||||
title: "Delete Combo",
|
title: "Delete Combo",
|
||||||
message: "Delete this combo?",
|
message: combo ? `Delete combo "${combo.name}"?` : "Delete this combo?",
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
setConfirmState(null);
|
setConfirmState((prev) => prev ? { ...prev, loading: true } : null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
|
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
|
||||||
if (res.ok) {
|
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) {
|
} catch (error) {
|
||||||
console.log("Error deleting combo:", 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.
|
// Merge a per-combo strategy patch into settings.comboStrategies.
|
||||||
// A "fallback" entry is only pruned when the global strategy is also fallback;
|
// 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.
|
// otherwise it's kept so the combo explicitly overrides global round-robin/fusion.
|
||||||
@@ -187,13 +336,7 @@ export default function CombosPage() {
|
|||||||
updated[comboName] = next;
|
updated[comboName] = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetch("/api/settings", {
|
await persistComboStrategies(updated);
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ comboStrategies: updated }),
|
|
||||||
});
|
|
||||||
|
|
||||||
setComboStrategies(updated);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error updating combo strategy:", error);
|
console.log("Error updating combo strategy:", error);
|
||||||
}
|
}
|
||||||
@@ -251,27 +394,45 @@ export default function CombosPage() {
|
|||||||
return tags.some((t) => activeTagFilters.has(t));
|
return tags.some((t) => activeTagFilters.has(t));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group by tag. A combo with [a, b] appears in both groups. Combos with no
|
// Name -> models map so a combo model that is itself a combo can resolve caps.
|
||||||
// tags land in a synthetic "__untagged__" bucket. Order within each group
|
const combosByName = Object.fromEntries(combos.map((c) => [c.name, c.models]));
|
||||||
// follows the input (combos are already in `createdAt ASC` from the repo).
|
const handleBulkSetStrategy = async (strategy) => {
|
||||||
const groupedCombos = (() => {
|
if (selectedCombos.length === 0 || !strategy) return;
|
||||||
const groups = new Map();
|
setBulkBusy(true);
|
||||||
for (const c of visibleCombos) {
|
try {
|
||||||
const tags = Array.isArray(c.tags) && c.tags.length > 0 ? c.tags : ["__untagged__"];
|
const updated = { ...comboStrategies };
|
||||||
for (const t of tags) {
|
for (const combo of selectedCombos) {
|
||||||
if (activeTagFilters.size > 0 && !activeTagFilters.has(t)) continue;
|
if (!strategy || strategy === "fallback") {
|
||||||
const arr = groups.get(t) || [];
|
delete updated[combo.name];
|
||||||
arr.push(c);
|
} else {
|
||||||
groups.set(t, arr);
|
updated[combo.name] = {
|
||||||
|
...(updated[combo.name] || {}),
|
||||||
|
fallbackStrategy: strategy,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await persistComboStrategies(updated);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error bulk updating combo strategy:", error);
|
||||||
|
alert("Failed to update strategy for selected combos");
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
}
|
}
|
||||||
return groups;
|
};
|
||||||
})();
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
<div className="flex min-w-0 flex-col gap-6 px-1 sm:px-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-text-muted mt-1">
|
<p className="text-sm text-text-muted mt-1">
|
||||||
Group models under one name, then pick a strategy per combo:
|
Group models under one name, then pick a strategy per combo:
|
||||||
@@ -281,10 +442,40 @@ export default function CombosPage() {
|
|||||||
<li><span className="font-medium text-text-main">Round Robin</span> — rotates models across requests to spread load</li>
|
<li><span className="font-medium text-text-main">Round Robin</span> — rotates models across requests to spread load</li>
|
||||||
<li><span className="font-medium text-text-main">Fusion</span> — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)</li>
|
<li><span className="font-medium text-text-main">Fusion</span> — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p className="hidden text-xs text-text-muted mt-3 max-w-2xl">
|
||||||
|
<span className="font-medium text-text-main">Cursor / Claude Default</span> create combos named exactly like those clients' model IDs (e.g. <code className="font-mono">composer-2.5</code>, <code className="font-mono">opus</code>), seeded with the matching <code className="font-mono">cu/…</code> or <code className="font-mono">cc/…</code> route so traffic can hit 9router without the prefix.
|
||||||
|
{" "}Note: Cursor IDE itself often blocks built-in Composer / Grok from Override OpenAI Base URL ("model does not support custom API"); add them via Cursor's <span className="font-medium text-text-main">Add Custom Model</span> using the combo name, or pick a model Cursor allows through the custom endpoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:items-stretch">
|
||||||
|
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
|
||||||
|
Create Combo
|
||||||
|
</Button>
|
||||||
|
<div className="hidden">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="edit_note"
|
||||||
|
loading={presetLoading === "cursor"}
|
||||||
|
disabled={!!presetLoading}
|
||||||
|
onClick={() => handleGeneratePresets("cursor")}
|
||||||
|
className="w-full whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Cursor Default
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon="smart_toy"
|
||||||
|
loading={presetLoading === "claude"}
|
||||||
|
disabled={!!presetLoading}
|
||||||
|
onClick={() => handleGeneratePresets("claude")}
|
||||||
|
className="w-full whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Claude Default
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto whitespace-nowrap">
|
|
||||||
Create Combo
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tag filter bar — chips toggle inclusion. OR semantics. */}
|
{/* Tag filter bar — chips toggle inclusion. OR semantics. */}
|
||||||
@@ -333,75 +524,108 @@ export default function CombosPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : activeTagFilters.size > 0 ? (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-3">
|
||||||
{[...groupedCombos.entries()].map(([tag, list]) => (
|
{/* Selection toolbar */}
|
||||||
<section key={tag} className="flex flex-col gap-3">
|
<div className="flex min-w-0 flex-col gap-2 rounded-lg border border-black/5 bg-black/[0.015] px-3 py-2 dark:border-white/5 dark:bg-white/[0.02] sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-2 px-1">
|
<label className="flex cursor-pointer items-center gap-2 text-xs text-text-muted hover:text-primary select-none">
|
||||||
<span className="material-symbols-outlined text-[16px] text-primary">sell</span>
|
<input
|
||||||
<h3 className="text-sm font-semibold">
|
type="checkbox"
|
||||||
{tag === "__untagged__" ? "Untagged" : tag}
|
checked={allSelected}
|
||||||
</h3>
|
ref={(el) => {
|
||||||
<span className="text-[11px] text-text-muted">({list.length})</span>
|
if (el) el.indeterminate = someSelected && !allSelected;
|
||||||
</div>
|
}}
|
||||||
|
onChange={toggleSelectAll}
|
||||||
|
className="h-3.5 w-3.5 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{someSelected
|
||||||
|
? `${selectedIds.length} selected`
|
||||||
|
: `Select all (${combos.length})`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
{someSelected && (
|
||||||
|
<>
|
||||||
|
<div className="w-full min-w-[160px] sm:w-[200px]">
|
||||||
|
<Select
|
||||||
|
options={STRATEGY_OPTIONS}
|
||||||
|
value=""
|
||||||
|
placeholder="Set strategy…"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (v) handleBulkSetStrategy(v);
|
||||||
|
}}
|
||||||
|
selectClassName="py-1.5 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
icon="delete"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
loading={bulkBusy}
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Delete ({selectedIds.length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={clearSelection}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
const { active, over } = e;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
const oldIndex = combos.findIndex((c) => c.id === active.id);
|
||||||
|
const newIndex = combos.findIndex((c) => c.id === over.id);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
handleReorder(oldIndex, newIndex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={combos.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{list.map((combo) => (
|
{combos.map((combo) => (
|
||||||
<ComboCard
|
<SortableComboCard key={combo.id} id={combo.id}>
|
||||||
key={combo.id}
|
{(handle) => (
|
||||||
combo={combo}
|
<ComboCard
|
||||||
getCaps={getCaps}
|
combo={combo}
|
||||||
activeProviders={activeProviders}
|
getCaps={getCaps}
|
||||||
copied={copied}
|
comboByName={combosByName}
|
||||||
onCopy={copy}
|
activeProviders={activeProviders}
|
||||||
onEdit={() => setEditingCombo(combo)}
|
copied={copied}
|
||||||
onDelete={() => handleDelete(combo.id)}
|
onCopy={copy}
|
||||||
strategy={comboStrategies[combo.name] || {}}
|
onEdit={() => setEditingCombo(combo)}
|
||||||
globalStrategy={globalComboStrategy}
|
onDelete={() => handleDelete(combo.id)}
|
||||||
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
strategy={comboStrategies[combo.name] || {}}
|
||||||
/>
|
globalStrategy={globalComboStrategy}
|
||||||
|
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
||||||
|
dragHandle={handle}
|
||||||
|
selected={selectedIds.includes(combo.id)}
|
||||||
|
onToggleSelect={() => toggleSelect(combo.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SortableComboCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</SortableContext>
|
||||||
))}
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
const { active, over } = e;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
const oldIndex = combos.findIndex((c) => c.id === active.id);
|
|
||||||
const newIndex = combos.findIndex((c) => c.id === over.id);
|
|
||||||
if (oldIndex < 0 || newIndex < 0) return;
|
|
||||||
handleReorder(oldIndex, newIndex);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SortableContext items={combos.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{combos.map((combo) => (
|
|
||||||
<SortableComboCard key={combo.id} id={combo.id}>
|
|
||||||
{(handle) => (
|
|
||||||
<ComboCard
|
|
||||||
combo={combo}
|
|
||||||
getCaps={getCaps}
|
|
||||||
activeProviders={activeProviders}
|
|
||||||
copied={copied}
|
|
||||||
onCopy={copy}
|
|
||||||
onEdit={() => setEditingCombo(combo)}
|
|
||||||
onDelete={() => handleDelete(combo.id)}
|
|
||||||
strategy={comboStrategies[combo.name] || {}}
|
|
||||||
globalStrategy={globalComboStrategy}
|
|
||||||
onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)}
|
|
||||||
dragHandle={handle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SortableComboCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
)}
|
||||||
<CapacityAdapterSection
|
<CapacityAdapterSection
|
||||||
capacityAdapter={capacityAdapter}
|
capacityAdapter={capacityAdapter}
|
||||||
@@ -432,38 +656,54 @@ export default function CombosPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirm Delete Modal */}
|
{/* Confirm (delete / generate presets) */}
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={!!confirmState}
|
isOpen={!!confirmState}
|
||||||
onClose={() => setConfirmState(null)}
|
onClose={() => !confirmState?.loading && setConfirmState(null)}
|
||||||
onConfirm={confirmState?.onConfirm}
|
onConfirm={confirmState?.onConfirm}
|
||||||
title={confirmState?.title || "Confirm"}
|
title={confirmState?.title || "Confirm"}
|
||||||
message={confirmState?.message}
|
message={confirmState?.message}
|
||||||
variant="danger"
|
confirmText={confirmState?.confirmText || "Confirm"}
|
||||||
|
variant={confirmState?.variant || "danger"}
|
||||||
|
loading={!!confirmState?.loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STRATEGY_OPTIONS = [
|
const fmtK = (n) => {
|
||||||
{ value: "fallback", label: "Fallback — try in order" },
|
if (!n) return "?";
|
||||||
{ value: "round-robin", label: "Round Robin — rotate" },
|
if (n >= 1000000) {
|
||||||
{ value: "fusion", label: "Fusion — panel + judge" },
|
const m = n / 1000000;
|
||||||
];
|
return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
return `${Math.round(n / 1000)}k`;
|
||||||
|
};
|
||||||
|
|
||||||
function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy, dragHandle = null }) {
|
function ComboCard({ combo, getCaps, comboByName = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, globalStrategy = "fallback", onSetStrategy, dragHandle = null, selected = false, onToggleSelect }) {
|
||||||
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
const [showJudgeSelect, setShowJudgeSelect] = useState(false);
|
||||||
// Show the effective strategy: per-combo override first, then the global
|
// Show the effective strategy: per-combo override first, then the global
|
||||||
// default (combos without an entry fall through to settings.comboStrategy).
|
// default (combos without an entry fall through to settings.comboStrategy).
|
||||||
const current = strategy.fallbackStrategy || globalStrategy || "fallback";
|
const current = strategy.fallbackStrategy || globalStrategy || "fallback";
|
||||||
const judge = strategy.judgeModel || "";
|
const judge = strategy.judgeModel || "";
|
||||||
const isFusion = current === "fusion";
|
const isFusion = current === "fusion";
|
||||||
|
const comboCaps = aggregateComboCapabilities(combo.models, comboByName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="sm" className="group">
|
<Card padding="sm" className={`group ${selected ? "ring-1 ring-primary/40 bg-primary/[0.03]" : ""}`}>
|
||||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
|
<div className="flex min-w-0 flex-1 items-start gap-3 sm:items-center">
|
||||||
{dragHandle}
|
{dragHandle}
|
||||||
|
<label className="flex shrink-0 items-center pt-1 sm:pt-0 cursor-pointer" title="Select combo">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={onToggleSelect}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
aria-label={`Select ${combo.name}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
|
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,7 +716,11 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
|
|||||||
combo.models.slice(0, 3).map((model, index) => (
|
combo.models.slice(0, 3).map((model, index) => (
|
||||||
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
|
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
|
||||||
<span>{model}</span>
|
<span>{model}</span>
|
||||||
<CapacityBadges caps={getCaps?.(model)} />
|
<CapacityBadges caps={
|
||||||
|
comboByName[model]
|
||||||
|
? aggregateComboCapabilities(comboByName[model], comboByName)
|
||||||
|
: getCaps?.(model)
|
||||||
|
} />
|
||||||
</code>
|
</code>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -495,6 +739,13 @@ function ComboCard({ combo, getCaps, activeProviders = [], copied, onCopy, onEdi
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{comboCaps && (
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[10px] text-text-muted">
|
||||||
|
<span>ctx {fmtK(comboCaps.contextWindow)}</span>
|
||||||
|
<span className="opacity-40">·</span>
|
||||||
|
<span>max {fmtK(comboCaps.maxOutput)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Fusion: judge picker (Auto = first model) */}
|
{/* Fusion: judge picker (Auto = first model) */}
|
||||||
{isFusion && (
|
{isFusion && (
|
||||||
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
@@ -618,10 +869,6 @@ function CapacityAdapterSection({ capacityAdapter, onChange, activeProviders, ge
|
|||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
Your model can't read image/audio? Auto-switches to a model in the pool below.
|
Your model can't read image/audio? Auto-switches to a model in the pool below.
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-1.5 text-[11px] text-text-muted flex flex-col gap-0.5">
|
|
||||||
<li><span className="font-medium text-text-main">Vision</span> — images (png, jpg, webp, …)</li>
|
|
||||||
<li><span className="font-medium text-text-main">Audio</span> — audio input</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -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_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png";
|
||||||
const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.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) {
|
function getImageEditDefaults(providerId, modelId) {
|
||||||
|
if (providerId === "huggingface") {
|
||||||
|
return { image: HUGGINGFACE_TEST_IMAGE_URL };
|
||||||
|
}
|
||||||
if (providerId !== "cloudflare-ai") return {};
|
if (providerId !== "cloudflare-ai") return {};
|
||||||
if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
|
if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
|
||||||
return { image: CLOUDFLARE_TEST_IMAGE_URL };
|
return { image: CLOUDFLARE_TEST_IMAGE_URL };
|
||||||
@@ -38,8 +44,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
|
|
||||||
// Get models for this kind (e.g., type="image")
|
// Get models for this kind (e.g., type="image")
|
||||||
const kindModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === kind);
|
const kindModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === kind);
|
||||||
// Kinds that need a model identifier in the request (image/video/music)
|
// Kinds that need a model identifier in the request (image/video/music/systemone)
|
||||||
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
|
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText", "systemone"]);
|
||||||
const needsModel = KIND_NEEDS_MODEL.has(kind);
|
const needsModel = KIND_NEEDS_MODEL.has(kind);
|
||||||
const allowManualModel = needsModel && kindModels.length === 0;
|
const allowManualModel = needsModel && kindModels.length === 0;
|
||||||
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
||||||
@@ -48,6 +54,7 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
|
const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
|
||||||
|
|
||||||
const [input, setInput] = useState(safeExConfig.defaultInput || "");
|
const [input, setInput] = useState(safeExConfig.defaultInput || "");
|
||||||
|
const [question, setQuestion] = useState("Does this request require urgent attention?");
|
||||||
const [refImage, setRefImage] = useState("");
|
const [refImage, setRefImage] = useState("");
|
||||||
const [maskImage, setMaskImage] = useState("");
|
const [maskImage, setMaskImage] = useState("");
|
||||||
const [extraValues, setExtraValues] = useState(() =>
|
const [extraValues, setExtraValues] = useState(() =>
|
||||||
@@ -111,11 +118,20 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
acc[k] = v;
|
acc[k] = v;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
const systemoneQuestions = kind === "systemone" ? {
|
||||||
|
questions: {
|
||||||
|
is_urgent: {
|
||||||
|
type: "noul",
|
||||||
|
instructions: question.trim() || "Does this request require urgent attention?",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} : {};
|
||||||
const requestBody = {
|
const requestBody = {
|
||||||
model: modelFull,
|
model: modelFull,
|
||||||
[exConfig.bodyKey]: input,
|
[exConfig.bodyKey]: input,
|
||||||
...exConfig.extraBody,
|
...exConfig.extraBody,
|
||||||
...extraBodyFromFields,
|
...extraBodyFromFields,
|
||||||
|
...systemoneQuestions,
|
||||||
...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
|
...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
|
||||||
...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
|
...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
|
||||||
};
|
};
|
||||||
@@ -322,6 +338,29 @@ export function GenericExampleCard({ providerId, kind }) {
|
|||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Question for System One */}
|
||||||
|
{kind === "systemone" && (
|
||||||
|
<Row label="Question">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
value={question}
|
||||||
|
onChange={(e) => setQuestion(e.target.value)}
|
||||||
|
placeholder="Enter evaluation question or criteria"
|
||||||
|
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
{question && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setQuestion("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Reference image (only for edit-capable image models) */}
|
{/* Reference image (only for edit-capable image models) */}
|
||||||
{supportsEdit && (
|
{supportsEdit && (
|
||||||
<Row label="Ref Image (URL)">
|
<Row label="Ref Image (URL)">
|
||||||
|
|||||||
@@ -75,4 +75,19 @@ export const KIND_EXAMPLE_CONFIG = {
|
|||||||
bodyKey: "prompt",
|
bodyKey: "prompt",
|
||||||
defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`,
|
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}`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -171,14 +171,15 @@ export default function MediaProviderDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, searchViaChat */}
|
{/* Provider Info — config-driven, supports searchConfig, fetchConfig, ttsConfig, embeddingConfig, systemoneConfig, searchViaChat */}
|
||||||
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.searchViaChat) && (
|
{!isCustom && (provider.searchConfig || provider.fetchConfig || provider.ttsConfig || provider.sttConfig || provider.embeddingConfig || provider.systemoneConfig || provider.searchViaChat) && (
|
||||||
<ProviderInfoCard
|
<ProviderInfoCard
|
||||||
config={
|
config={
|
||||||
kind === "webFetch" ? provider.fetchConfig
|
kind === "webFetch" ? provider.fetchConfig
|
||||||
: kind === "tts" ? provider.ttsConfig
|
: kind === "tts" ? provider.ttsConfig
|
||||||
: kind === "stt" ? provider.sttConfig
|
: kind === "stt" ? provider.sttConfig
|
||||||
: kind === "embedding" ? provider.embeddingConfig
|
: kind === "embedding" ? provider.embeddingConfig
|
||||||
|
: kind === "systemone" ? provider.systemoneConfig
|
||||||
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, pricingUrl: provider.searchViaChat?.pricingUrl, freeTier: provider.searchViaChat?.freeTier }
|
: provider.searchConfig || { mode: "chat-completions", defaultModel: provider.searchViaChat?.defaultModel, pricingUrl: provider.searchViaChat?.pricingUrl, freeTier: provider.searchViaChat?.freeTier }
|
||||||
}
|
}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
const isOllamaLocal = provider === "ollama-local";
|
const isOllamaLocal = provider === "ollama-local";
|
||||||
const isCookie = authType === "cookie";
|
const isCookie = authType === "cookie";
|
||||||
const isXaiApiKey = provider === "xai" && !isCookie;
|
const isXaiApiKey = provider === "xai" && !isCookie;
|
||||||
const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" ? "Personal Access Token (PAT)" : "API Key";
|
const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" || provider === "qoder-cn" ? "Personal Access Token (PAT)" : "API Key";
|
||||||
const credentialPlaceholder = isCookie
|
const credentialPlaceholder = isCookie
|
||||||
? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...")
|
? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...")
|
||||||
: (isXaiApiKey ? "xai-..." : provider === "qoder" ? "pt-..." : "");
|
: (isXaiApiKey ? "xai-..." : provider === "qoder" || provider === "qoder-cn" ? "pt-..." : "");
|
||||||
|
|
||||||
const isAzure = provider === "azure";
|
const isAzure = provider === "azure";
|
||||||
const isCloudflareAi = provider === "cloudflare-ai";
|
const isCloudflareAi = provider === "cloudflare-ai";
|
||||||
@@ -44,7 +44,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const bulkPlaceholder = isCloudflareAi
|
const bulkPlaceholder = isCloudflareAi
|
||||||
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
|
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
|
||||||
: provider === "qoder"
|
: provider === "qoder" || provider === "qoder-cn"
|
||||||
? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named`
|
? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named`
|
||||||
: BULK_PLACEHOLDER;
|
: BULK_PLACEHOLDER;
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
|
|||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
{isCloudflareAi
|
{isCloudflareAi
|
||||||
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
|
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
|
||||||
: provider === "qoder"
|
: provider === "qoder" || provider === "qoder-cn"
|
||||||
? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</>
|
? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</>
|
||||||
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
|
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export default function ProviderDetailPage() {
|
|||||||
const apiKeyConnectionLabel =
|
const apiKeyConnectionLabel =
|
||||||
providerId === "xai" ? "xAI API Key"
|
providerId === "xai" ? "xAI API Key"
|
||||||
: providerId === "kimi" ? "Kimi API Key"
|
: providerId === "kimi" ? "Kimi API Key"
|
||||||
: providerId === "qoder" ? "PAT"
|
: (providerId === "qoder" || providerId === "qoder-cn") ? "PAT"
|
||||||
: "API Key";
|
: "API Key";
|
||||||
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||||
// Capability store lives server-side; this bundle cannot read it, so the
|
// 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;
|
const modelId = model.id || model.name;
|
||||||
if (!modelId) continue;
|
if (!modelId) continue;
|
||||||
|
|
||||||
// Qoder model ID format may be "qoder/auto" or "auto", need to remove prefix
|
// Qoder model ID format may be "qoder/auto", "qoder-cn/auto" or "auto",
|
||||||
const cleanModelId = modelId.replace(/^qoder\//, "");
|
// need to remove the provider prefix before storing.
|
||||||
|
const cleanModelId = modelId.replace(/^(qoder-cn|qoder)\//, "");
|
||||||
const alreadyExists = customModels.some(
|
const alreadyExists = customModels.some(
|
||||||
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm"
|
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanModelId && (entry.kind || entry.type || "llm") === "llm"
|
||||||
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`);
|
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanModelId}`);
|
||||||
@@ -1676,8 +1677,8 @@ export default function ProviderDetailPage() {
|
|||||||
Add Model
|
Add Model
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Import Qoder models button — only show for qoder provider */}
|
{/* Import Qoder models button — only show for qoder/qoder-cn provider */}
|
||||||
{providerId === "qoder" && connections.some((conn) => conn.isActive !== false) && (
|
{(providerId === "qoder" || providerId === "qoder-cn") && connections.some((conn) => conn.isActive !== false) && (
|
||||||
<button
|
<button
|
||||||
onClick={handleImportQoderModels}
|
onClick={handleImportQoderModels}
|
||||||
disabled={importingQoderModels}
|
disabled={importingQoderModels}
|
||||||
|
|||||||
@@ -9,25 +9,25 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
|
|||||||
export default function OverviewCards({ stats }) {
|
export default function OverviewCards({ stats }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
|
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
|
||||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
|
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Total Requests</span>
|
||||||
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
|
<span className="w-full truncate text-lg font-bold xl:text-xl" title={fmt(stats.totalRequests)}>{fmt(stats.totalRequests)}</span>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
|
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Total Input Tokens</span>
|
||||||
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
|
<span className="w-full truncate text-lg font-bold text-primary xl:text-xl" title={fmt(stats.totalPromptTokens)}>{fmt(stats.totalPromptTokens)}</span>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
|
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Cached Tokens</span>
|
||||||
<span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span>
|
<span className="w-full truncate text-lg font-bold text-info xl:text-xl" title={fmt(stats.totalCachedTokens)}>{fmt(stats.totalCachedTokens)}</span>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
|
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Output Tokens</span>
|
||||||
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
|
<span className="w-full truncate text-lg font-bold text-success xl:text-xl" title={fmt(stats.totalCompletionTokens)}>{fmt(stats.totalCompletionTokens)}</span>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
|
<Card className="flex min-w-0 flex-col items-center text-center gap-1 px-3 py-3 sm:px-4">
|
||||||
<span className="text-text-muted text-sm uppercase font-semibold">Est. Cost</span>
|
<span className="text-text-muted text-xs uppercase font-semibold sm:text-sm">Est. Cost</span>
|
||||||
<span className="truncate text-2xl font-bold text-warning">~{fmtCost(stats.totalCost)}</span>
|
<span className="w-full truncate text-lg font-bold text-warning xl:text-xl" title={`~${fmtCost(stats.totalCost)}`}>~{fmtCost(stats.totalCost)}</span>
|
||||||
<span className="text-[10px] text-text-muted">Estimated, not actual billing</span>
|
<span className="text-[10px] text-text-muted">Estimated, not actual billing</span>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import Card from "@/shared/components/Card";
|
||||||
|
|
||||||
|
const COLORS = ["#6366f1", "#14b8a6", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#10b981", "#f97316"];
|
||||||
|
|
||||||
|
const fmtTokens = (n) => {
|
||||||
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
return String(n || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProviderBarChart({ byProvider }) {
|
||||||
|
const [viewMode, setViewMode] = useState("tokens");
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!byProvider) return [];
|
||||||
|
return Object.entries(byProvider)
|
||||||
|
.map(([id, data]) => ({
|
||||||
|
name: id,
|
||||||
|
tokens: (data.promptTokens || 0) + (data.completionTokens || 0),
|
||||||
|
requests: data.requests || 0,
|
||||||
|
}))
|
||||||
|
.filter((d) => d[viewMode] > 0)
|
||||||
|
.sort((a, b) => b[viewMode] - a[viewMode]);
|
||||||
|
}, [byProvider, viewMode]);
|
||||||
|
|
||||||
|
const fmt = viewMode === "tokens" ? fmtTokens : String;
|
||||||
|
const label = viewMode === "tokens" ? "Tokens" : "Requests";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold text-text-muted uppercase tracking-wide">By Provider</span>
|
||||||
|
<div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("tokens")}
|
||||||
|
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
||||||
|
>
|
||||||
|
Tokens
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("requests")}
|
||||||
|
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "requests" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
||||||
|
>
|
||||||
|
Requests
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!chartData.length ? (
|
||||||
|
<div className="h-44 flex items-center justify-center text-text-muted text-sm">No provider usage yet</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.6 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
tickFormatter={(v) => v.length > 10 ? v.slice(0, 10) + "…" : v}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={fmt}
|
||||||
|
width={44}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(value) => [fmt(value), label]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey={viewMode} radius={[4, 4, 0, 0]}>
|
||||||
|
{chartData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.85} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProviderBarChart.propTypes = {
|
||||||
|
byProvider: PropTypes.object,
|
||||||
|
};
|
||||||
@@ -45,6 +45,7 @@ export default function ProviderLimitCard({
|
|||||||
codex: "#10A37F",
|
codex: "#10A37F",
|
||||||
kiro: "#FF9900",
|
kiro: "#FF9900",
|
||||||
qoder: "#EC4899",
|
qoder: "#EC4899",
|
||||||
|
"qoder-cn": "#EC4899",
|
||||||
claude: "#D97757",
|
claude: "#D97757",
|
||||||
};
|
};
|
||||||
return colors[provider?.toLowerCase()] || "#6B7280";
|
return colors[provider?.toLowerCase()] || "#6B7280";
|
||||||
|
|||||||
@@ -377,51 +377,112 @@ export function parseQuotaData(provider, data) {
|
|||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
const entries = Object.entries(data.quotas);
|
const entries = Object.entries(data.quotas);
|
||||||
const weeklyKeys = new Set(["gemini_weekly", "claude_gpt_weekly"]);
|
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 geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
||||||
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
||||||
const imageModels = entries.filter(([k]) => k.includes("image"));
|
const imageModels = entries.filter(([k]) => k.includes("image"));
|
||||||
const weeklyModels = entries.filter(([k]) => 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") && !weeklyKeys.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) =>
|
const rep = geminiModels.reduce((min, cur) =>
|
||||||
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
||||||
)[1];
|
)[1];
|
||||||
normalizedQuotas.push({
|
// Only show synthesized Gemini row if its resetAt differs from weekly (i.e. it represents a separate 5h window)
|
||||||
name: "Gemini (Flash / Pro)",
|
const weeklyResetAt = data.quotas.gemini_weekly?.resetAt;
|
||||||
modelKey: "gemini",
|
const isDuplicateOfWeekly = hasGeminiWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0;
|
||||||
used: rep.used || 0,
|
|
||||||
total: rep.total || 0,
|
if (!isDuplicateOfWeekly) {
|
||||||
resetAt: rep.resetAt || null,
|
normalizedQuotas.push({
|
||||||
remainingPercentage: rep.remainingPercentage,
|
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) =>
|
const rep = claudeModels.reduce((min, cur) =>
|
||||||
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
(cur[1].remainingPercentage ?? 100) < (min[1].remainingPercentage ?? 100) ? cur : min
|
||||||
)[1];
|
)[1];
|
||||||
normalizedQuotas.push({
|
const weeklyResetAt = data.quotas.claude_gpt_weekly?.resetAt;
|
||||||
name: "Claude (Sonnet / Opus)",
|
const isDuplicateOfWeekly = hasClaudeWeekly && rep.resetAt === weeklyResetAt && (rep.remainingPercentage ?? 0) === 0;
|
||||||
modelKey: "claude",
|
|
||||||
used: rep.used || 0,
|
if (!isDuplicateOfWeekly) {
|
||||||
total: rep.total || 0,
|
normalizedQuotas.push({
|
||||||
resetAt: rep.resetAt || null,
|
name: "Claude (Sonnet / Opus)",
|
||||||
remainingPercentage: rep.remainingPercentage,
|
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]) => {
|
// 3. Standalone Image Generation Models (unique usage)
|
||||||
normalizedQuotas.push({
|
|
||||||
name: quota.displayName || modelKey,
|
|
||||||
modelKey,
|
|
||||||
used: quota.used || 0,
|
|
||||||
total: quota.total || 0,
|
|
||||||
resetAt: quota.resetAt || null,
|
|
||||||
remainingPercentage: quota.remainingPercentage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
imageModels.forEach(([modelKey, quota]) => {
|
imageModels.forEach(([modelKey, quota]) => {
|
||||||
normalizedQuotas.push({
|
normalizedQuotas.push({
|
||||||
name: quota.displayName || modelKey,
|
name: quota.displayName || modelKey,
|
||||||
@@ -433,16 +494,23 @@ export function parseQuotaData(provider, data) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
otherModels.forEach(([modelKey, quota]) => {
|
// 4. Other models:
|
||||||
normalizedQuotas.push({
|
// In Antigravity, GPT-OSS is explicitly documented by Google as part of the "Claude and GPT models" group:
|
||||||
name: quota.displayName || modelKey,
|
// ("Models within this group: Claude Opus, Claude Sonnet, GPT-OSS").
|
||||||
modelKey,
|
// When summary quotas (claude_gpt_session / claude_gpt_weekly) are present, GPT-OSS is already represented
|
||||||
used: quota.used || 0,
|
// by the "Claude & GPT" family rows. We only include otherModels if no summary exists for that pool.
|
||||||
total: quota.total || 0,
|
if (!hasClaudeWeekly && !hasClaudeSession) {
|
||||||
resetAt: quota.resetAt || null,
|
otherModels.forEach(([modelKey, quota]) => {
|
||||||
remainingPercentage: quota.remainingPercentage,
|
normalizedQuotas.push({
|
||||||
|
name: quota.displayName || modelKey,
|
||||||
|
modelKey,
|
||||||
|
used: quota.used || 0,
|
||||||
|
total: quota.total || 0,
|
||||||
|
resetAt: quota.resetAt || null,
|
||||||
|
remainingPercentage: quota.remainingPercentage,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -483,6 +551,7 @@ export function parseQuotaData(provider, data) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "qoder":
|
case "qoder":
|
||||||
|
case "qoder-cn":
|
||||||
// Qoder ships a `user` quota and (optionally) an `organization`
|
// Qoder ships a `user` quota and (optionally) an `organization`
|
||||||
// quota, both with same shape: {total, used, remaining, unit, resetAt}.
|
// quota, both with same shape: {total, used, remaining, unit, resetAt}.
|
||||||
// Skip an organization bucket when its total is 0 — most personal
|
// Skip an organization bucket when its total is 0 — most personal
|
||||||
@@ -632,7 +701,7 @@ export function parseQuotaData(provider, data) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "ollama":
|
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 %).
|
// remainingPercentage only — no absolute remaining (UI treats remaining as %).
|
||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
Object.entries(data.quotas).forEach(([name, quota]) => {
|
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
|
// Use modelKey for antigravity (mapped to family anchor), otherwise use name
|
||||||
let keyA = a.modelKey || a.name;
|
let keyA = a.modelKey || a.name;
|
||||||
let keyB = b.modelKey || b.name;
|
let keyB = b.modelKey || b.name;
|
||||||
if (keyA === "gemini") keyA = "gemini-3.8-flash-high";
|
if (keyA === "gemini" || keyA === "gemini_session") keyA = "gemini-3.8-flash-high";
|
||||||
if (keyA === "claude") keyA = "claude-sonnet-4-6";
|
if (keyA === "claude" || keyA === "claude_gpt_session") keyA = "claude-sonnet-4-6";
|
||||||
if (keyB === "gemini") keyB = "gemini-3.8-flash-high";
|
if (keyB === "gemini" || keyB === "gemini_session") keyB = "gemini-3.8-flash-high";
|
||||||
if (keyB === "claude") keyB = "claude-sonnet-4-6";
|
if (keyB === "claude" || keyB === "claude_gpt_session") keyB = "claude-sonnet-4-6";
|
||||||
const orderA = orderMap.get(keyA) ?? 999;
|
const orderA = orderMap.get(keyA) ?? 999;
|
||||||
const orderB = orderMap.get(keyB) ?? 999;
|
const orderB = orderMap.get(keyB) ?? 999;
|
||||||
return orderA - orderB;
|
return orderA - orderB;
|
||||||
|
|||||||
114
src/app/(dashboard)/dashboard/usage/components/TopModelsChart.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
import Card from "@/shared/components/Card";
|
||||||
|
|
||||||
|
const COLORS = ["#6366f1", "#14b8a6", "#f59e0b", "#ef4444", "#8b5cf6"];
|
||||||
|
|
||||||
|
const fmtTokens = (n) => {
|
||||||
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
return String(n || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncate = (s, max = 22) => (s && s.length > max ? s.slice(0, max) + "…" : s || "");
|
||||||
|
|
||||||
|
export default function TopModelsChart({ byModel }) {
|
||||||
|
const [viewMode, setViewMode] = useState("tokens");
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!byModel) return [];
|
||||||
|
return Object.values(byModel)
|
||||||
|
.map((data) => ({
|
||||||
|
name: truncate(data.rawModel || "Unknown"),
|
||||||
|
tokens: (data.promptTokens || 0) + (data.completionTokens || 0),
|
||||||
|
requests: data.requests || 0,
|
||||||
|
}))
|
||||||
|
.filter((d) => d[viewMode] > 0)
|
||||||
|
.sort((a, b) => b[viewMode] - a[viewMode])
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [byModel, viewMode]);
|
||||||
|
|
||||||
|
const fmt = viewMode === "tokens" ? fmtTokens : String;
|
||||||
|
const label = viewMode === "tokens" ? "Tokens" : "Requests";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold text-text-muted uppercase tracking-wide">Top Models</span>
|
||||||
|
<div className="grid grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("tokens")}
|
||||||
|
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
||||||
|
>
|
||||||
|
Tokens
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("requests")}
|
||||||
|
className={`px-2.5 py-0.5 rounded-md text-xs font-medium transition-colors ${viewMode === "requests" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
||||||
|
>
|
||||||
|
Requests
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!chartData.length ? (
|
||||||
|
<div className="h-44 flex items-center justify-center text-text-muted text-sm">No model usage yet</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 4, right: 40, left: 4, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.1} horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={fmt}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.7 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={90}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--color-bg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
formatter={(value) => [fmt(value), label]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey={viewMode} radius={[0, 4, 4, 0]}>
|
||||||
|
{chartData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} fillOpacity={0.85} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TopModelsChart.propTypes = {
|
||||||
|
byModel: PropTypes.object,
|
||||||
|
};
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import Card from "@/shared/components/Card";
|
import Card from "@/shared/components/Card";
|
||||||
|
|
||||||
@@ -21,6 +20,19 @@ const fmtTokens = (n) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fmtCost = (n) => `$${(n || 0).toFixed(4)}`;
|
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" }) {
|
export default function UsageChart({ period = "7d" }) {
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
@@ -46,23 +58,24 @@ export default function UsageChart({ period = "7d" }) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [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 (
|
return (
|
||||||
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
|
<Card className="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
|
||||||
<div className="grid w-full grid-cols-2 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start">
|
<div
|
||||||
<button
|
className="grid w-full items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:w-auto sm:self-start"
|
||||||
onClick={() => setViewMode("tokens")}
|
style={{ gridTemplateColumns: `repeat(${VIEW_MODES.length}, minmax(0, 1fr))` }}
|
||||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "tokens" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
>
|
||||||
>
|
{VIEW_MODES.map((m) => (
|
||||||
Tokens
|
<button
|
||||||
</button>
|
key={m.value}
|
||||||
<button
|
onClick={() => setViewMode(m.value)}
|
||||||
onClick={() => setViewMode("cost")}
|
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === m.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
||||||
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${viewMode === "cost" ? "bg-primary text-white shadow-sm" : "text-text-muted hover:text-text hover:bg-bg-hover"}`}
|
>
|
||||||
>
|
{m.label}
|
||||||
Cost
|
</button>
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -77,6 +90,10 @@ export default function UsageChart({ period = "7d" }) {
|
|||||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.25} />
|
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.25} />
|
||||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="gradRequests" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#14b8a6" stopOpacity={0.25} />
|
||||||
|
<stop offset="95%" stopColor="#14b8a6" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="gradCost" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
|
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.25} />
|
||||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
|
||||||
@@ -94,7 +111,7 @@ export default function UsageChart({ period = "7d" }) {
|
|||||||
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
|
tick={{ fontSize: 10, fill: "currentColor", fillOpacity: 0.5 }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={viewMode === "tokens" ? fmtTokens : fmtCost}
|
tickFormatter={cfg.formatter}
|
||||||
width={50}
|
width={50}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -104,31 +121,17 @@ export default function UsageChart({ period = "7d" }) {
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
formatter={(value, name) =>
|
formatter={(value) => [cfg.formatter(value), cfg.label]}
|
||||||
name === "tokens" ? [fmtTokens(value), "Tokens"] : [fmtCost(value), "Cost"]
|
/>
|
||||||
}
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey={cfg.dataKey}
|
||||||
|
stroke={cfg.color}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={`url(#${cfg.gradId})`}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
/>
|
/>
|
||||||
{viewMode === "tokens" ? (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="tokens"
|
|
||||||
stroke="#6366f1"
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#gradTokens)"
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 4 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="cost"
|
|
||||||
stroke="#f59e0b"
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#gradCost)"
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 4 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const PERIODS = [
|
|||||||
{ value: "7d", label: "7D" },
|
{ value: "7d", label: "7D" },
|
||||||
{ value: "30d", label: "30D" },
|
{ value: "30d", label: "30D" },
|
||||||
{ value: "60d", label: "60D" },
|
{ value: "60d", label: "60D" },
|
||||||
|
{ value: "all", label: "All" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function UsagePage() {
|
export default function UsagePage() {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route";
|
|||||||
import { GET as jcodeGet } from "../jcode-settings/route";
|
import { GET as jcodeGet } from "../jcode-settings/route";
|
||||||
import { GET as grokBuildGet } from "../grok-build-settings/route";
|
import { GET as grokBuildGet } from "../grok-build-settings/route";
|
||||||
import { GET as devinGet } from "../devin-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 = {
|
const STATUS_GETTERS = {
|
||||||
claude: claudeGet,
|
claude: claudeGet,
|
||||||
@@ -29,6 +35,12 @@ const STATUS_GETTERS = {
|
|||||||
jcode: jcodeGet,
|
jcode: jcodeGet,
|
||||||
"grok-build": grokBuildGet,
|
"grok-build": grokBuildGet,
|
||||||
devin: devinGet,
|
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
|
// Batch endpoint: gather all CLI tool statuses in one round-trip
|
||||||
|
|||||||
142
src/app/api/cli-tools/codewhale-settings/route.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { parseTOML, stringifyTOML } from "confbox";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const getCodewhaleDir = () => path.join(os.homedir(), ".codewhale");
|
||||||
|
const getCodewhaleConfigPath = () => path.join(getCodewhaleDir(), "config.toml");
|
||||||
|
|
||||||
|
const checkCodewhaleInstalled = async () => {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
try {
|
||||||
|
const command = isWindows ? "where codewhale" : "which codewhale";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getCodewhaleConfigPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const has9RouterConfig = (content) => {
|
||||||
|
if (!content) return false;
|
||||||
|
return content.includes("managed by 9Router") || content.includes("localhost:20128");
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async () => {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(getCodewhaleConfigPath(), "utf-8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const installed = await checkCodewhaleInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
config: null,
|
||||||
|
message: "CodeWhale CLI is not installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readConfig();
|
||||||
|
let config = null;
|
||||||
|
try {
|
||||||
|
if (content) config = parseTOML(content);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
config,
|
||||||
|
has9Router: has9RouterConfig(content),
|
||||||
|
configPath: getCodewhaleConfigPath(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, apiKey, model } = rawBody || {};
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = getCodewhaleConfigPath();
|
||||||
|
await fs.mkdir(getCodewhaleDir(), { recursive: true });
|
||||||
|
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = parseTOML(raw);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
|
||||||
|
existing.openai = {
|
||||||
|
base_url: normalizedBaseUrl,
|
||||||
|
api_key: apiKey || "sk_9router",
|
||||||
|
model: model || "provider/model-id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = "# CodeWhale config — managed by 9Router\n\n";
|
||||||
|
const content = header + stringifyTOML(existing);
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, content, "utf-8");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "CodeWhale settings applied successfully!",
|
||||||
|
configPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const configPath = getCodewhaleConfigPath();
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = parseTOML(raw);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete existing.openai;
|
||||||
|
|
||||||
|
if (Object.keys(existing).length === 0) {
|
||||||
|
await fs.rm(configPath, { force: true });
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(configPath, stringifyTOML(existing), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "9Router removed from CodeWhale" });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/app/api/cli-tools/crush-settings/route.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const getCrushConfigPath = () => {
|
||||||
|
const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||||
|
return path.join(configDir, "crush", "crush.json");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCrushDir = () => path.dirname(getCrushConfigPath());
|
||||||
|
|
||||||
|
const checkCrushInstalled = async () => {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
try {
|
||||||
|
const command = isWindows ? "where crush" : "which crush";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getCrushConfigPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const has9RouterConfig = (settings) => {
|
||||||
|
if (!settings || !settings.providers) return false;
|
||||||
|
const p = settings.providers["9router"];
|
||||||
|
if (p && p.base_url) return true;
|
||||||
|
for (const prov of Object.values(settings.providers)) {
|
||||||
|
if (prov.base_url && prov.base_url.includes("20128")) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async () => {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(getCrushConfigPath(), "utf-8");
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const installed = await checkCrushInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
config: null,
|
||||||
|
message: "Crush CLI is not installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
config,
|
||||||
|
has9Router: has9RouterConfig(config),
|
||||||
|
configPath: getCrushConfigPath(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, apiKey, model } = rawBody || {};
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = getCrushConfigPath();
|
||||||
|
await fs.mkdir(getCrushDir(), { recursive: true });
|
||||||
|
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
/* No existing config */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.providers) existing.providers = {};
|
||||||
|
|
||||||
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
const modelId = model || "provider/model-id";
|
||||||
|
|
||||||
|
existing.providers["9router"] = {
|
||||||
|
type: "openai-compat",
|
||||||
|
base_url: normalizedBaseUrl,
|
||||||
|
api_key: apiKey || "sk_9router",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: modelId,
|
||||||
|
name: modelId,
|
||||||
|
context_window: 128000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Crush settings applied successfully!",
|
||||||
|
configPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const configPath = getCrushConfigPath();
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.providers && existing.providers["9router"]) {
|
||||||
|
delete existing.providers["9router"];
|
||||||
|
if (Object.keys(existing.providers).length === 0) delete existing.providers;
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "9Router removed from Crush" });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/app/api/cli-tools/forge-settings/route.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import { parseTOML, stringifyTOML } from "confbox";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const getForgeDir = () => path.join(os.homedir(), ".forge");
|
||||||
|
const getForgeConfigPath = () => path.join(getForgeDir(), "config.toml");
|
||||||
|
|
||||||
|
const checkForgeInstalled = async () => {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
try {
|
||||||
|
const command = isWindows ? "where forge" : "which forge";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getForgeConfigPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const has9RouterConfig = (content) => {
|
||||||
|
if (!content) return false;
|
||||||
|
return content.includes("managed by 9Router") || content.includes("localhost:20128");
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async () => {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(getForgeConfigPath(), "utf-8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const installed = await checkForgeInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
config: null,
|
||||||
|
message: "ForgeCode CLI is not installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await readConfig();
|
||||||
|
let config = null;
|
||||||
|
try {
|
||||||
|
if (content) config = parseTOML(content);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
config,
|
||||||
|
has9Router: has9RouterConfig(content),
|
||||||
|
configPath: getForgeConfigPath(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, apiKey, model } = rawBody || {};
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = getForgeConfigPath();
|
||||||
|
await fs.mkdir(getForgeDir(), { recursive: true });
|
||||||
|
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = parseTOML(raw);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
|
||||||
|
existing.openai = {
|
||||||
|
api_key: apiKey || "sk_9router",
|
||||||
|
base_url: normalizedBaseUrl,
|
||||||
|
model: model || "provider/model-id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = "# Forge config — managed by 9Router\n\n";
|
||||||
|
const content = header + stringifyTOML(existing);
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, content, "utf-8");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "ForgeCode settings applied successfully!",
|
||||||
|
configPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const configPath = getForgeConfigPath();
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = parseTOML(raw);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete existing.openai;
|
||||||
|
|
||||||
|
if (Object.keys(existing).length === 0) {
|
||||||
|
await fs.rm(configPath, { force: true });
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(configPath, stringifyTOML(existing), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "9Router removed from ForgeCode" });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/app/api/cli-tools/omp-settings/route.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const PROVIDER_ID = "9router";
|
||||||
|
const getOmpDir = () => path.join(os.homedir(), ".omp", "agent");
|
||||||
|
const getOmpDbPath = () => path.join(getOmpDir(), "agent.db");
|
||||||
|
const getOmpModelsYmlPath = () => path.join(getOmpDir(), "models.yml");
|
||||||
|
|
||||||
|
const checkOmpInstalled = async () => {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
try {
|
||||||
|
const command = isWindows ? "where omp" : "which omp";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getOmpDbPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getOmpModelsYmlPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readModelsYml = async () => {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(getOmpModelsYmlPath(), "utf-8");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const has9RouterInYml = (content) => {
|
||||||
|
if (!content) return false;
|
||||||
|
return content.includes("9router:") || content.includes("localhost:20128");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build standard 9Router provider block for models.yml
|
||||||
|
const buildOmpProviderYaml = (baseUrl, apiKey) => {
|
||||||
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
const key = apiKey || "sk_9router";
|
||||||
|
return ` ${PROVIDER_ID}:
|
||||||
|
baseUrl: ${normalizedBaseUrl}
|
||||||
|
apiKey: ${key}
|
||||||
|
api: openai-completions
|
||||||
|
authHeader: true
|
||||||
|
disableStrictTools: true
|
||||||
|
discovery:
|
||||||
|
type: proxy`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const installed = await checkOmpInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
config: null,
|
||||||
|
message: "Oh My Pi is not installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ymlContent = await readModelsYml();
|
||||||
|
const has9Router = has9RouterInYml(ymlContent);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
has9Router,
|
||||||
|
configPath: getOmpModelsYmlPath(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, apiKey } = rawBody || {};
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(getOmpDir(), { recursive: true });
|
||||||
|
|
||||||
|
let ymlContent = await readModelsYml();
|
||||||
|
const providerBlock = buildOmpProviderYaml(baseUrl, apiKey);
|
||||||
|
|
||||||
|
// Remove existing 9router provider if present
|
||||||
|
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
|
||||||
|
ymlContent = ymlContent.replace(regex, "");
|
||||||
|
|
||||||
|
if (!ymlContent.trim()) {
|
||||||
|
ymlContent = `providers:\n${providerBlock}\n`;
|
||||||
|
} else if (ymlContent.includes("providers:")) {
|
||||||
|
ymlContent = ymlContent.replace(/providers:/, `providers:\n${providerBlock}`);
|
||||||
|
} else {
|
||||||
|
ymlContent = `${ymlContent.trim()}\n\nproviders:\n${providerBlock}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(getOmpModelsYmlPath(), ymlContent, "utf-8");
|
||||||
|
|
||||||
|
// Best-effort update to agent.db if better-sqlite3 or node:sqlite is present
|
||||||
|
try {
|
||||||
|
let Database;
|
||||||
|
try {
|
||||||
|
const mod = await import("better-sqlite3");
|
||||||
|
Database = mod.default || mod;
|
||||||
|
} catch {
|
||||||
|
// fallback ignored
|
||||||
|
}
|
||||||
|
if (Database) {
|
||||||
|
const dbPath = getOmpDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.prepare("DELETE FROM auth_credentials WHERE provider = ?").run(PROVIDER_ID);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?, ?, ?, NULL, NULL, ?, ?)"
|
||||||
|
).run(
|
||||||
|
PROVIDER_ID,
|
||||||
|
"api_key",
|
||||||
|
JSON.stringify({ apiKey: apiKey || "sk_9router", baseUrl }),
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
Math.floor(Date.now() / 1000)
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical: models.yml is primary
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Oh My Pi settings applied! Run 'omp' and all 9Router models appear under 9router in /model.",
|
||||||
|
configPath: getOmpModelsYmlPath(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
let ymlContent = await readModelsYml();
|
||||||
|
const regex = new RegExp(`\\s*${PROVIDER_ID}:[\\s\\S]*?(?=\\n\\s*\\w+:|$)`, "g");
|
||||||
|
ymlContent = ymlContent.replace(regex, "");
|
||||||
|
|
||||||
|
if (ymlContent.trim() === "providers:") {
|
||||||
|
await fs.rm(getOmpModelsYmlPath(), { force: true });
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(getOmpModelsYmlPath(), ymlContent, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "9Router removed from Oh My Pi",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/app/api/cli-tools/pi-settings/route.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const getPiModelsJsonPath = () => {
|
||||||
|
const agentPath = path.join(os.homedir(), ".pi", "agent", "models.json");
|
||||||
|
return agentPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPiDir = () => path.dirname(getPiModelsJsonPath());
|
||||||
|
|
||||||
|
const checkPiInstalled = async () => {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
try {
|
||||||
|
const command = isWindows ? "where pi" : "which pi";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getPiModelsJsonPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(os.homedir(), ".pi", "models.json"));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const has9RouterConfig = (settings) => {
|
||||||
|
if (!settings || !settings.providers) return false;
|
||||||
|
const p = settings.providers["9router"];
|
||||||
|
if (p && p.baseUrl) return true;
|
||||||
|
for (const prov of Object.values(settings.providers)) {
|
||||||
|
if (prov.baseUrl && prov.baseUrl.includes("20128")) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveModelsJsonPath = async () => {
|
||||||
|
const agentPath = path.join(os.homedir(), ".pi", "agent", "models.json");
|
||||||
|
const rootPath = path.join(os.homedir(), ".pi", "models.json");
|
||||||
|
try {
|
||||||
|
await fs.access(agentPath);
|
||||||
|
return agentPath;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(rootPath);
|
||||||
|
return rootPath;
|
||||||
|
} catch {
|
||||||
|
return agentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async () => {
|
||||||
|
try {
|
||||||
|
const targetPath = await resolveModelsJsonPath();
|
||||||
|
const content = await fs.readFile(targetPath, "utf-8");
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const installed = await checkPiInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
config: null,
|
||||||
|
message: "Pi CLI is not installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
const configPath = await resolveModelsJsonPath();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
config,
|
||||||
|
has9Router: has9RouterConfig(config),
|
||||||
|
configPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, apiKey, model } = rawBody || {};
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = await resolveModelsJsonPath();
|
||||||
|
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||||
|
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
/* No existing config */
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing.providers) existing.providers = {};
|
||||||
|
|
||||||
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
let modelList = [];
|
||||||
|
if (Array.isArray(rawBody.models) && rawBody.models.length > 0) {
|
||||||
|
modelList = rawBody.models.map((m) => {
|
||||||
|
if (typeof m === "string") {
|
||||||
|
return { id: m, name: m, contextWindow: 128000, maxTokens: 16384 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: m.id || "provider/model-id",
|
||||||
|
name: m.name || m.id || "provider/model-id",
|
||||||
|
contextWindow: m.contextWindow || 128000,
|
||||||
|
maxTokens: m.maxTokens || 16384,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const modelId = model || "provider/model-id";
|
||||||
|
modelList = [{ id: modelId, name: modelId, contextWindow: 128000, maxTokens: 16384 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.providers["9router"] = {
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
apiKey: apiKey || "sk_9router",
|
||||||
|
api: "openai-completions",
|
||||||
|
models: modelList,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Pi settings applied! Use /model in Pi to select the 9Router model.",
|
||||||
|
configPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const configPath = await resolveModelsJsonPath();
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.providers && existing.providers["9router"]) {
|
||||||
|
delete existing.providers["9router"];
|
||||||
|
if (Object.keys(existing.providers).length === 0) delete existing.providers;
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "9Router removed from Pi" });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/app/api/cli-tools/smelt-settings/route.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
const getSmeltConfigPath = () => path.join(os.homedir(), ".smelt", "config.json");
|
||||||
|
const getSmeltDir = () => path.dirname(getSmeltConfigPath());
|
||||||
|
|
||||||
|
const checkSmeltInstalled = async () => {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
try {
|
||||||
|
const command = isWindows ? "where smelt" : "which smelt";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await fs.access(getSmeltConfigPath());
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const has9RouterConfig = (settings) => {
|
||||||
|
if (!settings) return false;
|
||||||
|
return (
|
||||||
|
settings._managedBy === "9router" ||
|
||||||
|
(typeof settings.baseUrl === "string" && settings.baseUrl.length > 0 && settings.baseUrl.includes("20128"))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async () => {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(getSmeltConfigPath(), "utf-8");
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const installed = await checkSmeltInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
config: null,
|
||||||
|
message: "Smelt CLI is not installed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
config,
|
||||||
|
has9Router: has9RouterConfig(config),
|
||||||
|
configPath: getSmeltConfigPath(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
let rawBody;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { baseUrl, apiKey, model } = rawBody || {};
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: { message: "baseUrl is required" } }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = getSmeltConfigPath();
|
||||||
|
await fs.mkdir(getSmeltDir(), { recursive: true });
|
||||||
|
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = JSON.parse(raw);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
|
||||||
|
const updated = {
|
||||||
|
...existing,
|
||||||
|
baseUrl: normalizedBaseUrl,
|
||||||
|
apiKey: apiKey || "sk_9router",
|
||||||
|
model: model || existing.model || "provider/model-id",
|
||||||
|
_managedBy: "9router",
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(updated, null, 2), "utf-8");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "Smelt settings applied successfully!",
|
||||||
|
configPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
const configPath = getSmeltConfigPath();
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(configPath, "utf-8");
|
||||||
|
existing = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ success: true, message: "No config file to reset" });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete existing.baseUrl;
|
||||||
|
delete existing.apiKey;
|
||||||
|
delete existing.model;
|
||||||
|
delete existing._managedBy;
|
||||||
|
|
||||||
|
if (Object.keys(existing).length === 0) {
|
||||||
|
await fs.rm(configPath, { force: true });
|
||||||
|
} else {
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(existing, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: "Smelt 9Router settings removed" });
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: { message: err.message } }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/app/api/combos/presets/route.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCombos, createCombo, getProviderConnections } from "@/lib/localDb";
|
||||||
|
import { buildPresetItems, PRESET_SOURCES } from "@/lib/comboPresets";
|
||||||
|
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve live Cursor catalog from the first active cursor connection, if any.
|
||||||
|
* @returns {Promise<Array<{id: string, name?: string}>|null>}
|
||||||
|
*/
|
||||||
|
async function fetchCursorLiveModels() {
|
||||||
|
try {
|
||||||
|
const connections = await getProviderConnections();
|
||||||
|
const conn = (connections || []).find(
|
||||||
|
(c) => c.provider === "cursor" && c.isActive !== false
|
||||||
|
);
|
||||||
|
if (!conn) return null;
|
||||||
|
const result = await resolveCursorModels({
|
||||||
|
accessToken: conn.accessToken,
|
||||||
|
providerSpecificData: conn.providerSpecificData || {},
|
||||||
|
}, { log: console });
|
||||||
|
return result?.models?.length ? result.models : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("combo presets: cursor live catalog failed", error?.message || error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} source
|
||||||
|
* @returns {Promise<{ name: string, models: string[], exists: boolean }[]>}
|
||||||
|
*/
|
||||||
|
async function resolvePresetItems(source) {
|
||||||
|
const combos = await getCombos();
|
||||||
|
const existingNames = (combos || []).map((c) => c.name);
|
||||||
|
const liveModels = source === "cursor" ? await fetchCursorLiveModels() : null;
|
||||||
|
return buildPresetItems(source, {
|
||||||
|
liveModels: liveModels || undefined,
|
||||||
|
existingNames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSource(value) {
|
||||||
|
if (!value || !PRESET_SOURCES.has(value)) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/combos/presets?source=cursor|claude — preview items
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const source = parseSource(searchParams.get("source"));
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "source must be 'cursor' or 'claude'" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await resolvePresetItems(source);
|
||||||
|
return NextResponse.json({
|
||||||
|
source,
|
||||||
|
items,
|
||||||
|
toCreate: items.filter((i) => !i.exists).length,
|
||||||
|
toSkip: items.filter((i) => i.exists).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error previewing combo presets:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to preview combo presets" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/combos/presets — create missing combos for a source
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const source = parseSource(body?.source);
|
||||||
|
if (!source) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "source must be 'cursor' or 'claude'" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await resolvePresetItems(source);
|
||||||
|
const created = [];
|
||||||
|
const skipped = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.exists) {
|
||||||
|
skipped.push(item.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const combo = await createCombo({ name: item.name, models: item.models });
|
||||||
|
created.push(combo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
source,
|
||||||
|
created,
|
||||||
|
skipped,
|
||||||
|
createdCount: created.length,
|
||||||
|
skippedCount: skipped.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error creating combo presets:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to create combo presets" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,36 @@ export async function pingModelByKind(
|
|||||||
return { ok: true, latencyMs, error: null, status: res.status };
|
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`, {
|
const res = await fetch(`${baseUrl}/api/v1/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ export async function GET(request, { params }) {
|
|||||||
"codebuddy-cn",
|
"codebuddy-cn",
|
||||||
"codebuddy-intl",
|
"codebuddy-intl",
|
||||||
"qoder",
|
"qoder",
|
||||||
|
"qoder-cn",
|
||||||
"grok-cli",
|
"grok-cli",
|
||||||
];
|
];
|
||||||
let deviceData;
|
let deviceData;
|
||||||
@@ -505,7 +506,7 @@ export async function POST(request, { params }) {
|
|||||||
} else if (provider === "kiro") {
|
} else if (provider === "kiro") {
|
||||||
// Kiro needs extraData (clientId, clientSecret) from device code response
|
// Kiro needs extraData (clientId, clientSecret) from device code response
|
||||||
result = await pollForToken(provider, deviceCode, null, extraData);
|
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
|
// Qoder needs both the PKCE verifier (codeVerifier) and the machineId
|
||||||
// captured at device-code time (extraData._qoderMachineId) so
|
// captured at device-code time (extraData._qoderMachineId) so
|
||||||
// mapTokens can persist it for COSY signing.
|
// mapTokens can persist it for COSY signing.
|
||||||
|
|||||||
@@ -10,17 +10,19 @@ import { createProviderConnection } from "@/models";
|
|||||||
*/
|
*/
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
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(
|
return NextResponse.json(
|
||||||
{ error: "API key is required" },
|
{ error: "API key is required" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = apiKey.trim();
|
if (key && !key.startsWith("sk-")) {
|
||||||
if (!key.startsWith("sk-")) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid key format — expected sk- prefix" },
|
{ error: "Invalid key format — expected sk- prefix" },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
@@ -29,47 +31,55 @@ export async function POST(request) {
|
|||||||
|
|
||||||
const effectiveBaseUrl = (baseUrl || "https://api.xiaomimimo.com/v1").replace(/\/+$/, "");
|
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 validated = false;
|
||||||
let modelCount = 0;
|
let modelCount = 0;
|
||||||
try {
|
if (key) {
|
||||||
const resp = await fetch(`${effectiveBaseUrl}/models`, {
|
try {
|
||||||
method: "GET",
|
const resp = await fetch(`${effectiveBaseUrl}/models`, {
|
||||||
headers: {
|
method: "GET",
|
||||||
Authorization: `Bearer ${key}`,
|
headers: {
|
||||||
"X-Mimo-Source": "mimocode-cli",
|
Authorization: `Bearer ${key}`,
|
||||||
},
|
"X-Mimo-Source": "mimocode-cli",
|
||||||
signal: AbortSignal.timeout(10000),
|
},
|
||||||
});
|
signal: AbortSignal.timeout(10000),
|
||||||
if (resp.ok) {
|
});
|
||||||
const data = await resp.json();
|
if (resp.ok) {
|
||||||
modelCount = Array.isArray(data?.data) ? data.data.length : 0;
|
const data = await resp.json();
|
||||||
validated = true;
|
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
|
// Soft-fail: store the key but mark as untested
|
||||||
console.log("[xiaomi-mimo] key validation failed, storing 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 { getProviderConnections, updateProviderConnection } = await import("@/models");
|
||||||
|
const normRegion = (typeof region === "string" && region) || undefined;
|
||||||
const existing = (await getProviderConnections()).find(
|
const existing = (await getProviderConnections()).find(
|
||||||
(c) => c.provider === "xiaomi-mimo" && (
|
(c) => c.provider === "xiaomi-mimo" && (
|
||||||
(uid && c.email === `${uid}@xiaomi`) ||
|
(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) {
|
if (existing) {
|
||||||
const updated = await updateProviderConnection(existing.id, {
|
const updated = await updateProviderConnection(existing.id, {
|
||||||
accessToken: key,
|
accessToken: key || existing.accessToken,
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
...existing.providerSpecificData,
|
...existing.providerSpecificData,
|
||||||
uid: uid || existing.providerSpecificData?.uid || null,
|
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.
|
// Per-account session credential — enables multi-account rotation.
|
||||||
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
|
mimoPassToken: mimoPassToken || existing.providerSpecificData?.mimoPassToken || null,
|
||||||
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
|
mimoUserId: mimoUserId || existing.providerSpecificData?.mimoUserId || null,
|
||||||
@@ -94,25 +104,29 @@ export async function POST(request) {
|
|||||||
|
|
||||||
const connection = await createProviderConnection({
|
const connection = await createProviderConnection({
|
||||||
provider: "xiaomi-mimo",
|
provider: "xiaomi-mimo",
|
||||||
authType: "api_key",
|
// "oauth" is the official authType for imported credential connections
|
||||||
accessToken: key,
|
// ([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,
|
refreshToken: null,
|
||||||
// API keys don't expire on a fixed schedule; use a long horizon
|
// API keys don't expire on a fixed schedule; use a long horizon
|
||||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
email: uid ? `${uid}@xiaomi` : null,
|
email: uid ? `${uid}@xiaomi` : null,
|
||||||
displayName: uid ? `Xiaomi ${uid}` : "Xiaomi MiMo",
|
displayName: uid ? `Xiaomi ${uid}${sessionOnly ? " (Session)" : ""}` : "Xiaomi MiMo",
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
uid: uid || null,
|
uid: uid || null,
|
||||||
baseUrl: effectiveBaseUrl,
|
baseUrl: effectiveBaseUrl,
|
||||||
authMethod: "api_key",
|
authMethod: sessionOnly ? "session" : "api_key",
|
||||||
provider: "API Key",
|
provider: sessionOnly ? "Session Login" : "API Key",
|
||||||
|
region: normRegion || "cn",
|
||||||
modelCount,
|
modelCount,
|
||||||
// Per-account session credential — enables multi-account rotation.
|
// Per-account session credential — enables multi-account rotation.
|
||||||
mimoPassToken: mimoPassToken || null,
|
mimoPassToken: mimoPassToken || null,
|
||||||
mimoUserId: mimoUserId || null,
|
mimoUserId: mimoUserId || null,
|
||||||
mimoCUserId: mimoCUserId || null,
|
mimoCUserId: mimoCUserId || null,
|
||||||
},
|
},
|
||||||
testStatus: validated ? "active" : "untested",
|
testStatus: validated ? "active" : (sessionOnly ? "active" : "untested"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
140
src/app/api/oauth/xiaomi-mimo/login/start/route.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { request as httpRequest } from "node:http";
|
||||||
|
import { beginSession, encodeSessionCookie, rewriteMimoBases, absorbSetCookies as absorbResponseCookies, originOf, loginUpstreamFetch, SESSION_COOKIE } from "@/lib/mimoLoginSession";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/oauth/xiaomi-mimo/login/start
|
||||||
|
* Body: { region: "cn" | "sgp" | "ams" | "ru" | "in" }
|
||||||
|
*
|
||||||
|
* Walks the first two hops of the Desktop login surface server-side
|
||||||
|
* (me -> 302 account/pass/serviceLogin -> 302 /fe/service/login) and hands
|
||||||
|
* the browser a same-origin pageUrl carrying the 9r_mimo_login session cookie.
|
||||||
|
* All subsequent account.xiaomi.com traffic flows through src/proxy.js.
|
||||||
|
*
|
||||||
|
* Egress resolution: MIMO_LOGIN_PROXY env > (region=sgp: probe common LOCAL
|
||||||
|
* HTTP proxy ports — v2rayN/clash defaults) > direct. The resolved URL rides
|
||||||
|
* the session cookie so every hop/XHR uses the same exit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_UA =
|
||||||
|
"miNative PC/Normal Windows_NT/10.0.19045 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0";
|
||||||
|
const SSO_UA = "MiClaw/1.0";
|
||||||
|
const LOCAL_PROXY_PORTS = [10808, 10809, 7890, 7891, 1080, 1081, 8080, 8888];
|
||||||
|
|
||||||
|
/** First local port answering a CONNECT to account.xiaomi.com (or null). */
|
||||||
|
function probeLocalHttpProxy(timeoutMs = 500) {
|
||||||
|
const attempts = LOCAL_PROXY_PORTS.map(
|
||||||
|
(port) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (v) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
// Promise.any picks the first FULFILLED value — failures must reject,
|
||||||
|
// otherwise an instant ECONNREFUSED from a closed candidate port would
|
||||||
|
// "win" with null before the real proxy answers.
|
||||||
|
if (v) resolve(v);
|
||||||
|
else reject(new Error(`no-proxy-${port}`));
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const req = httpRequest({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
method: "CONNECT",
|
||||||
|
path: "account.xiaomi.com:443",
|
||||||
|
timeout: timeoutMs,
|
||||||
|
});
|
||||||
|
req.on("connect", (res, socket) => {
|
||||||
|
socket.destroy();
|
||||||
|
done(res.statusCode === 200 || res.statusCode === 202 ? `http://127.0.0.1:${port}` : null);
|
||||||
|
});
|
||||||
|
req.on("timeout", () => { req.destroy(); done(null); });
|
||||||
|
req.on("error", () => done(null));
|
||||||
|
req.on("response", () => done(null));
|
||||||
|
req.end();
|
||||||
|
} catch {
|
||||||
|
done(null);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return Promise.any(attempts).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hop(sess, url, ua) {
|
||||||
|
return loginUpstreamFetch(url, {
|
||||||
|
redirect: "manual",
|
||||||
|
headers: { "User-Agent": ua, Accept: "text/html,application/json,*/*" },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
}, sess);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
let region = "cn";
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const r = String(body?.region || "").toLowerCase();
|
||||||
|
// Known MiMo Desktop clusters (cn/sgp/ams/ru/in) — default cn.
|
||||||
|
if (r === "cn" || r === "sgp" || r === "ams" || r === "ru" || r === "in") region = r;
|
||||||
|
} catch { /* empty body — default cn */ }
|
||||||
|
|
||||||
|
const sess = beginSession(region);
|
||||||
|
|
||||||
|
// Egress — non-CN clusters may need an overseas exit for the login page's
|
||||||
|
// geo-decided features (e.g. Google sign-in); CN is always direct.
|
||||||
|
let egress = null;
|
||||||
|
let egressSource = "direct";
|
||||||
|
if (region !== "cn") {
|
||||||
|
const found = await probeLocalHttpProxy();
|
||||||
|
if (found) {
|
||||||
|
egress = found;
|
||||||
|
egressSource = "local-probe";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sess.proxyUrl = egress;
|
||||||
|
|
||||||
|
// Hop 1: me -> account SSO (callback carries the sts callback for THIS cluster)
|
||||||
|
const meRes = await hop(sess, `${sess.upstreamBase}/api/user/xiaomi/me`, API_UA);
|
||||||
|
absorbResponseCookies(sess, meRes, `${sess.upstreamBase}/api/user/xiaomi/me`);
|
||||||
|
const ssoLoc = meRes.headers.get("location");
|
||||||
|
if (!ssoLoc || !/account\.xiaomi\.com/.test(ssoLoc)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unexpected me response (${meRes.status}) — no account redirect` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hop 2: serviceLogin -> /fe/service/login SPA (also seeds deviceId cookies)
|
||||||
|
const loginRes = await hop(sess, ssoLoc, SSO_UA);
|
||||||
|
absorbResponseCookies(sess, loginRes, ssoLoc);
|
||||||
|
const pageLoc = loginRes.headers.get("location");
|
||||||
|
if (!pageLoc) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unexpected serviceLogin response (${loginRes.status})` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin path for the SPA (middleware proxies native prefixes).
|
||||||
|
const pageUrl = new URL(pageLoc, "https://account.xiaomi.com");
|
||||||
|
const origin = originOf(request);
|
||||||
|
// Session travels ONLY in the httpOnly cookie — never in the URL (history,
|
||||||
|
// logs, Referer). /login/status re-arms the cookie on every poll, so a
|
||||||
|
// dropped-cookie browser still recovers on the next poll cycle.
|
||||||
|
const proxiedPath = rewriteMimoBases(pageUrl.pathname + pageUrl.search, "toProxy", origin);
|
||||||
|
const egressLog = egress ? egress.replace(/\/\/[^@/]+@/, "//***@") : "";
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start region=${sess.region} origin=${origin} egress=${egressSource}${egressLog ? ` (${egressLog})` : ""} page=${pageUrl.pathname}`);
|
||||||
|
|
||||||
|
const res = NextResponse.json({ success: true, state: sess.state, pageUrl: proxiedPath, region });
|
||||||
|
res.cookies.set(SESSION_COOKIE, encodeSessionCookie(sess), {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 15 * 60,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${new Date().toISOString().slice(11,23)} [mimo-login] start error:`, error?.message || error);
|
||||||
|
return NextResponse.json({ error: error?.message || "login start failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/oauth/xiaomi-mimo/login/status/route.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { sessionFromRequest, readSessionIdentity, attachSessionCookie } from "@/lib/mimoLoginSession";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/oauth/xiaomi-mimo/login/status?state=...
|
||||||
|
* Polls the server-side login session (state lives in the httpOnly session
|
||||||
|
* cookie — route handlers and the proxy don't share module memory). When a
|
||||||
|
* passToken is in the jar, probes /api/user/xiaomi/me once to confirm the
|
||||||
|
* session works, then returns the identity for the client to persist.
|
||||||
|
*/
|
||||||
|
export async function GET(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const state = url.searchParams.get("state") || "";
|
||||||
|
const sess = sessionFromRequest(request);
|
||||||
|
if (!sess || (state && sess.state !== state)) {
|
||||||
|
return NextResponse.json({ status: "expired" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sess.status !== "done") {
|
||||||
|
// AUTHORIZATION = passToken in the jar (captured during the proxied login
|
||||||
|
// XHRs). No serviceToken exchange — weekly-quota API moved; re-wire later.
|
||||||
|
if (readSessionIdentity(sess)) sess.status = "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sess.status !== "done") {
|
||||||
|
// Re-arm the session cookie on every poll — the modal may sit on the login
|
||||||
|
// form much longer than the 15min TTL, and only proxied responses used to
|
||||||
|
// refresh it (browser silently drops an expired cookie before the POST).
|
||||||
|
return attachSessionCookie(NextResponse.json({ status: "pending", region: sess.region }), sess);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = readSessionIdentity(sess);
|
||||||
|
if (!id) {
|
||||||
|
return attachSessionCookie(
|
||||||
|
NextResponse.json({ status: "error", error: "session captured but passToken missing" }),
|
||||||
|
sess,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { status: "done", region: sess.region, ...id };
|
||||||
|
// One-shot: don't let the identity linger past the client reading it.
|
||||||
|
const res = NextResponse.json(payload);
|
||||||
|
res.cookies.set("9r_mimo_login", "", { path: "/", httpOnly: true, maxAge: 0 });
|
||||||
|
return res;
|
||||||
|
}
|
||||||