Merge remote-tracking branch 'origin/main' into feat/account-level-proxy
# Conflicts: # src/server/routes/proxy/chat.ts # src/server/routes/proxy/responses.ts
This commit is contained in:
+417
-443
@@ -1,443 +1,417 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload built dist
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: metapi-dist-${{ github.sha }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-packages:
|
||||
name: Build Desktop Package (${{ matrix.platform }})
|
||||
needs: verify
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
runner: ubuntu-latest
|
||||
artifactLabel: linux-x64
|
||||
- platform: windows
|
||||
runner: windows-latest
|
||||
artifactLabel: windows-x64
|
||||
- platform: mac-arm64
|
||||
runner: macos-15
|
||||
artifactLabel: mac-arm64
|
||||
packageCommand: npm run package:desktop -- --mac --arm64
|
||||
expectedMacArch: arm64
|
||||
- platform: mac-x64
|
||||
runner: macos-15-intel
|
||||
artifactLabel: mac-x64
|
||||
packageCommand: npm run package:desktop:mac:intel
|
||||
expectedMacArch: x64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Restore Electron cache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/.cache/electron
|
||||
${{ runner.temp }}/.cache/electron-builder
|
||||
${{ env.LOCALAPPDATA }}\electron\Cache
|
||||
${{ env.LOCALAPPDATA }}\electron-builder\Cache
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Restore Electron cache (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/.cache/electron
|
||||
${{ runner.temp }}/.cache/electron-builder
|
||||
${{ runner.temp }}/.cache/appimage-tools
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Restore Electron cache (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/.cache/electron
|
||||
${{ runner.temp }}/.cache/electron-builder
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download built dist
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: metapi-dist-${{ github.sha }}
|
||||
path: dist
|
||||
|
||||
- name: Build desktop artifacts (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
npm_config_cache: ${{ runner.temp }}/.npm
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT }
|
||||
if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT }
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
Write-Host "Desktop build attempt $attempt/2"
|
||||
npm run package:desktop
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
break
|
||||
}
|
||||
if ($attempt -eq 2) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Start-Sleep -Seconds 90
|
||||
}
|
||||
|
||||
- name: Smoke test packaged native modules (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
ELECTRON_RUN_AS_NODE: 1
|
||||
run: |
|
||||
$appRoot = Join-Path $PWD 'release\win-unpacked\resources\app'
|
||||
$exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe'
|
||||
Push-Location $appRoot
|
||||
try {
|
||||
& $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
- name: Build desktop artifacts (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
npm_config_cache: ${{ runner.temp }}/.npm
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
echo "Desktop build attempt ${attempt}/2"
|
||||
if npm run package:desktop; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 90
|
||||
done
|
||||
|
||||
- name: Build desktop artifacts (macOS)
|
||||
if: startsWith(matrix.platform, 'mac-')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
npm_config_cache: ${{ runner.temp }}/.npm
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
APPLE_ID_INPUT: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PACKAGE_COMMAND: ${{ matrix.packageCommand }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi
|
||||
if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi
|
||||
if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi
|
||||
for attempt in 1 2; do
|
||||
echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2"
|
||||
if eval "${PACKAGE_COMMAND}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 90
|
||||
done
|
||||
|
||||
- name: Verify packaged macOS architecture
|
||||
if: startsWith(matrix.platform, 'mac-')
|
||||
shell: bash
|
||||
env:
|
||||
expectedMacArch: ${{ matrix.expectedMacArch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t app_binaries < <(find release -type f -path '*/Metapi.app/Contents/MacOS/Metapi')
|
||||
if [ "${#app_binaries[@]}" -eq 0 ]; then
|
||||
echo "No packaged macOS app binary found under release/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for binary in "${app_binaries[@]}"; do
|
||||
archs="$(lipo -archs "$binary" 2>/dev/null | xargs)"
|
||||
echo "$binary => ${archs:-unknown}"
|
||||
if [ -z "${archs}" ]; then
|
||||
echo "Unable to detect architecture via lipo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${expectedMacArch}" = "x64" ]; then
|
||||
[ "${archs}" = "x86_64" ] || {
|
||||
echo "Expected x86_64-only binary but got: ${archs}"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
[ "${archs}" = "arm64" ] || {
|
||||
echo "Expected arm64-only binary but got: ${archs}"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Upload desktop package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }}
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.blockmap
|
||||
release/latest*.yml
|
||||
release/latest*.json
|
||||
release/builder-debug.yml
|
||||
if-no-files-found: error
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
needs: build-packages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Show release files
|
||||
run: ls -la release-assets
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: release-assets/*
|
||||
|
||||
publish-docker-arch:
|
||||
name: Publish Docker Image (${{ matrix.arch }})
|
||||
needs: build-packages
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Ensure Docker Hub secrets are configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
1467078763/metapi
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Add architecture suffix to tags
|
||||
id: arch-tags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -f tags.txt
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
echo "${tag}-${{ matrix.arch }}" >> tags.txt
|
||||
done <<< "${{ steps.meta.outputs.tags }}"
|
||||
{
|
||||
echo 'tags<<EOF'
|
||||
cat tags.txt
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push ${{ matrix.platform }} image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.arch-tags.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }}
|
||||
|
||||
publish-docker:
|
||||
name: Publish Docker Manifests
|
||||
needs: publish-docker-arch
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Ensure Docker Hub secrets are configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
1467078763/metapi
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Create multi-arch manifests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
"${tag}-amd64" \
|
||||
"${tag}-arm64"
|
||||
done <<< "${{ steps.meta.outputs.tags }}"
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload built dist
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: metapi-dist-${{ github.sha }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-packages:
|
||||
name: Build Desktop Package (${{ matrix.platform }})
|
||||
needs: verify
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
runner: ubuntu-latest
|
||||
artifactLabel: linux-x64
|
||||
- platform: windows
|
||||
runner: windows-latest
|
||||
artifactLabel: windows-x64
|
||||
- platform: mac-arm64
|
||||
runner: macos-15
|
||||
artifactLabel: mac-arm64
|
||||
packageCommand: npm run package:desktop -- --mac --arm64
|
||||
expectedMacArch: arm64
|
||||
- platform: mac-x64
|
||||
runner: macos-15-intel
|
||||
artifactLabel: mac-x64
|
||||
packageCommand: npm run package:desktop:mac:intel
|
||||
expectedMacArch: x64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Restore Electron cache (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/.cache/electron
|
||||
${{ runner.temp }}/.cache/electron-builder
|
||||
${{ env.LOCALAPPDATA }}\electron\Cache
|
||||
${{ env.LOCALAPPDATA }}\electron-builder\Cache
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Restore Electron cache (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/.cache/electron
|
||||
${{ runner.temp }}/.cache/electron-builder
|
||||
${{ runner.temp }}/.cache/appimage-tools
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Restore Electron cache (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/.cache/electron
|
||||
${{ runner.temp }}/.cache/electron-builder
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
key: electron-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'package.json', 'electron-builder.yml') }}
|
||||
restore-keys: |
|
||||
electron-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download built dist
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: metapi-dist-${{ github.sha }}
|
||||
path: dist
|
||||
|
||||
- name: Build desktop artifacts (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
npm_config_cache: ${{ runner.temp }}/.npm
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
WIN_CSC_LINK_INPUT: ${{ secrets.CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD_INPUT: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
if ($env:WIN_CSC_LINK_INPUT) { $env:WIN_CSC_LINK = $env:WIN_CSC_LINK_INPUT }
|
||||
if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT }
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
Write-Host "Desktop build attempt $attempt/2"
|
||||
npm run package:desktop
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
break
|
||||
}
|
||||
if ($attempt -eq 2) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Start-Sleep -Seconds 90
|
||||
}
|
||||
|
||||
- name: Smoke test packaged native modules (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
env:
|
||||
ELECTRON_RUN_AS_NODE: 1
|
||||
run: |
|
||||
$appRoot = Join-Path $PWD 'release\win-unpacked\resources\app'
|
||||
$exePath = Join-Path $PWD 'release\win-unpacked\Metapi.exe'
|
||||
Push-Location $appRoot
|
||||
try {
|
||||
& $exePath -e "require('./node_modules/better-sqlite3'); console.log('better-sqlite3 ok')"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
- name: Build desktop artifacts (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
npm_config_cache: ${{ runner.temp }}/.npm
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2; do
|
||||
echo "Desktop build attempt ${attempt}/2"
|
||||
if npm run package:desktop; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 90
|
||||
done
|
||||
|
||||
- name: Build desktop artifacts (macOS)
|
||||
if: startsWith(matrix.platform, 'mac-')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
npm_config_cache: ${{ runner.temp }}/.npm
|
||||
ELECTRON_CACHE: ${{ runner.temp }}/.cache/electron
|
||||
ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
APPLE_ID_INPUT: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PACKAGE_COMMAND: ${{ matrix.packageCommand }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi
|
||||
if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi
|
||||
if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi
|
||||
for attempt in 1 2; do
|
||||
echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2"
|
||||
if eval "${PACKAGE_COMMAND}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 2 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 90
|
||||
done
|
||||
|
||||
- name: Verify packaged macOS architecture
|
||||
if: startsWith(matrix.platform, 'mac-')
|
||||
shell: bash
|
||||
env:
|
||||
expectedMacArch: ${{ matrix.expectedMacArch }}
|
||||
run: |
|
||||
node scripts/desktop/verifyMacArchitecture.mjs --release-dir release --expected-arch "$expectedMacArch"
|
||||
|
||||
- name: Upload desktop package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }}
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.blockmap
|
||||
release/latest*.yml
|
||||
release/latest*.json
|
||||
release/builder-debug.yml
|
||||
if-no-files-found: error
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
needs: build-packages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Download package artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Show release files
|
||||
run: ls -la release-assets
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
files: release-assets/*
|
||||
|
||||
publish-docker-arch:
|
||||
name: Publish Docker Image (${{ matrix.arch }})
|
||||
needs: build-packages
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Ensure Docker Hub secrets are configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
1467078763/metapi
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Add architecture suffix to tags
|
||||
id: arch-tags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -f tags.txt
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
echo "${tag}-${{ matrix.arch }}" >> tags.txt
|
||||
done <<< "${{ steps.meta.outputs.tags }}"
|
||||
{
|
||||
echo 'tags<<EOF'
|
||||
cat tags.txt
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push ${{ matrix.platform }} image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
tags: ${{ steps.arch-tags.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }}
|
||||
|
||||
publish-docker:
|
||||
name: Publish Docker Manifests
|
||||
needs: publish-docker-arch
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Ensure Docker Hub secrets are configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.DOCKERHUB_USERNAME }}" ] || [ -z "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "Missing Docker Hub secrets: DOCKERHUB_USERNAME / DOCKERHUB_TOKEN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
1467078763/metapi
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Create multi-arch manifests
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
while IFS= read -r tag; do
|
||||
[ -n "$tag" ] || continue
|
||||
docker buildx imagetools create \
|
||||
--tag "$tag" \
|
||||
"${tag}-amd64" \
|
||||
"${tag}-arm64"
|
||||
done <<< "${{ steps.meta.outputs.tags }}"
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('release workflow', () => {
|
||||
it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => {
|
||||
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
|
||||
|
||||
expect(workflow).toContain('runner: macos-15-intel');
|
||||
expect(workflow).toContain('runner: macos-15');
|
||||
expect(workflow).toContain('expectedMacArch: x64');
|
||||
expect(workflow).toContain('expectedMacArch: arm64');
|
||||
expect(workflow).toContain('Verify packaged macOS architecture');
|
||||
expect(workflow).toContain('lipo -archs');
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
describe('release workflow', () => {
|
||||
it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => {
|
||||
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
|
||||
|
||||
expect(workflow).toContain('runner: macos-15-intel');
|
||||
expect(workflow).toContain('runner: macos-15');
|
||||
expect(workflow).toContain('expectedMacArch: x64');
|
||||
expect(workflow).toContain('expectedMacArch: arm64');
|
||||
expect(workflow).toContain('Verify packaged macOS architecture');
|
||||
expect(workflow).toContain('node scripts/desktop/verifyMacArchitecture.mjs');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const MAC_BINARY_SEGMENTS = ['Metapi.app', 'Contents', 'MacOS', 'Metapi'];
|
||||
|
||||
function walkDirectories(rootDir) {
|
||||
const queue = [rootDir];
|
||||
const files = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentDir = queue.shift();
|
||||
if (!currentDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
queue.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export function findPackagedMacBinaries(releaseDir) {
|
||||
const normalizedSuffix = MAC_BINARY_SEGMENTS.join('/');
|
||||
|
||||
return walkDirectories(releaseDir).filter((filePath) =>
|
||||
filePath.replaceAll('\\', '/').endsWith(normalizedSuffix),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeExpectedMacArch(expectedArch) {
|
||||
if (expectedArch === 'x64') {
|
||||
return 'x86_64';
|
||||
}
|
||||
|
||||
if (expectedArch === 'arm64') {
|
||||
return 'arm64';
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported expected mac architecture: ${expectedArch}`);
|
||||
}
|
||||
|
||||
export function inspectBinaryArchsWithLipo(binaryPath) {
|
||||
try {
|
||||
return execFileSync('lipo', ['-archs', binaryPath], { encoding: 'utf8' }).trim();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyMacArchitecture({
|
||||
releaseDir,
|
||||
expectedArch,
|
||||
inspectBinaryArchs = inspectBinaryArchsWithLipo,
|
||||
}) {
|
||||
const binaries = findPackagedMacBinaries(releaseDir);
|
||||
if (binaries.length === 0) {
|
||||
throw new Error(`No packaged macOS app binary found under ${releaseDir}`);
|
||||
}
|
||||
|
||||
const expectedBinaryArch = normalizeExpectedMacArch(expectedArch);
|
||||
|
||||
return binaries.map((binaryPath) => {
|
||||
const archs = inspectBinaryArchs(binaryPath).trim().replace(/\s+/g, ' ');
|
||||
if (!archs) {
|
||||
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}`);
|
||||
}
|
||||
|
||||
if (archs !== expectedBinaryArch) {
|
||||
throw new Error(`Expected ${expectedBinaryArch}-only binary but got: ${archs}`);
|
||||
}
|
||||
|
||||
return { binaryPath, archs };
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgValue(args, name) {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return args[index + 1];
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const releaseDirArg = parseArgValue(args, '--release-dir');
|
||||
const expectedArch = parseArgValue(args, '--expected-arch');
|
||||
|
||||
if (!releaseDirArg || !expectedArch) {
|
||||
throw new Error('Usage: node scripts/desktop/verifyMacArchitecture.mjs --release-dir <dir> --expected-arch <x64|arm64>');
|
||||
}
|
||||
|
||||
const releaseDir = resolve(releaseDirArg);
|
||||
const verifiedBinaries = verifyMacArchitecture({ releaseDir, expectedArch });
|
||||
|
||||
for (const { binaryPath, archs } of verifiedBinaries) {
|
||||
console.log(`${binaryPath} => ${archs}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { verifyMacArchitecture } from './verifyMacArchitecture.mjs';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createReleaseBinary() {
|
||||
const releaseDir = mkdtempSync(join(tmpdir(), 'metapi-mac-arch-'));
|
||||
tempDirs.push(releaseDir);
|
||||
|
||||
const binaryPath = join(releaseDir, 'mac-x64', 'Metapi.app', 'Contents', 'MacOS', 'Metapi');
|
||||
mkdirSync(dirname(binaryPath), { recursive: true });
|
||||
writeFileSync(binaryPath, 'binary');
|
||||
|
||||
return { releaseDir, binaryPath };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('verifyMacArchitecture', () => {
|
||||
it('accepts x64 binaries when lipo reports x86_64', () => {
|
||||
const { releaseDir, binaryPath } = createReleaseBinary();
|
||||
|
||||
expect(
|
||||
verifyMacArchitecture({
|
||||
releaseDir,
|
||||
expectedArch: 'x64',
|
||||
inspectBinaryArchs: () => 'x86_64',
|
||||
}),
|
||||
).toEqual([{ archs: 'x86_64', binaryPath }]);
|
||||
});
|
||||
|
||||
it('rejects x64 binaries when lipo reports arm64', () => {
|
||||
const { releaseDir } = createReleaseBinary();
|
||||
|
||||
expect(() =>
|
||||
verifyMacArchitecture({
|
||||
releaseDir,
|
||||
expectedArch: 'x64',
|
||||
inspectBinaryArchs: () => 'arm64',
|
||||
}),
|
||||
).toThrow(/Expected x86_64-only binary but got: arm64/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CliProfileDefinition } from './types.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isClaudeSurface(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath === '/v1/messages'
|
||||
|| normalizedPath === '/anthropic/v1/messages'
|
||||
|| normalizedPath === '/v1/messages/count_tokens';
|
||||
}
|
||||
|
||||
export function extractClaudeCodeSessionId(userId: string): string | null {
|
||||
const trimmed = userId.trim();
|
||||
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
|
||||
|
||||
const sessionPrefix = '__session_';
|
||||
const sessionIndex = trimmed.lastIndexOf(sessionPrefix);
|
||||
if (sessionIndex === -1) return null;
|
||||
|
||||
const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim();
|
||||
return sessionId || null;
|
||||
}
|
||||
|
||||
export const claudeCodeCliProfile: CliProfileDefinition = {
|
||||
id: 'claude_code',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
detect(input) {
|
||||
if (!isClaudeSurface(input.downstreamPath)) return null;
|
||||
if (!isRecord(input.body) || !isRecord(input.body.metadata)) return null;
|
||||
|
||||
const userId = typeof input.body.metadata.user_id === 'string'
|
||||
? input.body.metadata.user_id.trim()
|
||||
: '';
|
||||
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
|
||||
if (!sessionId) return null;
|
||||
|
||||
return {
|
||||
id: 'claude_code',
|
||||
sessionId,
|
||||
traceHint: sessionId,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CliProfileDefinition, DetectCliProfileInput } from './types.js';
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
||||
if (!headers) return null;
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||
return headerValueToString(rawValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: string): boolean {
|
||||
if (!headers) return false;
|
||||
const normalizedPrefix = prefix.trim().toLowerCase();
|
||||
return Object.entries(headers).some(([rawKey, rawValue]) => {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
return key.startsWith(normalizedPrefix) && !!headerValueToString(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
function isCodexPath(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath.startsWith('/v1/responses')
|
||||
|| normalizedPath === '/v1/chat/completions'
|
||||
|| normalizedPath.startsWith('/v1/messages');
|
||||
}
|
||||
|
||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||
return isCodexRequest({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export function getCodexSessionId(headers?: Record<string, unknown>): string | null {
|
||||
return getHeaderValue(headers, 'session_id') || getHeaderValue(headers, 'session-id');
|
||||
}
|
||||
|
||||
export function isCodexRequest(input: DetectCliProfileInput): boolean {
|
||||
if (!isCodexPath(input.downstreamPath)) return false;
|
||||
const headers = input.headers;
|
||||
if (!headers) return false;
|
||||
|
||||
const originator = getHeaderValue(headers, 'originator');
|
||||
if (originator?.toLowerCase() === 'codex_cli_rs') return true;
|
||||
if (getHeaderValue(headers, 'openai-beta')) return true;
|
||||
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
||||
if (getCodexSessionId(headers)) return true;
|
||||
if (getHeaderValue(headers, 'x-codex-turn-state')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export const codexCliProfile: CliProfileDefinition = {
|
||||
id: 'codex',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: true,
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
detect(input) {
|
||||
if (!isCodexRequest(input)) return null;
|
||||
|
||||
const sessionId = getCodexSessionId(input.headers) || undefined;
|
||||
return {
|
||||
id: 'codex',
|
||||
...(sessionId ? { sessionId, traceHint: sessionId } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { CliProfileDefinition } from './types.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isGeminiCliPath(path: string): boolean {
|
||||
const normalizedPath = path.trim().toLowerCase();
|
||||
return normalizedPath === '/v1internal:generatecontent'
|
||||
|| normalizedPath === '/v1internal:streamgeneratecontent'
|
||||
|| normalizedPath === '/v1internal:counttokens';
|
||||
}
|
||||
|
||||
function hasGeminiCliBodyShape(body: unknown): boolean {
|
||||
if (!isRecord(body)) return false;
|
||||
return typeof body.model === 'string'
|
||||
|| Array.isArray(body.contents)
|
||||
|| (isRecord(body.request) && (Array.isArray(body.request.contents) || typeof body.request.model === 'string'));
|
||||
}
|
||||
|
||||
export const geminiCliProfile: CliProfileDefinition = {
|
||||
id: 'gemini_cli',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
detect(input) {
|
||||
if (!isGeminiCliPath(input.downstreamPath)) return null;
|
||||
if (input.body !== undefined && !hasGeminiCliBodyShape(input.body)) return null;
|
||||
return {
|
||||
id: 'gemini_cli',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CliProfileDefinition } from './types.js';
|
||||
|
||||
export const genericCliProfile: CliProfileDefinition = {
|
||||
id: 'generic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
detect() {
|
||||
return {
|
||||
id: 'generic',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectCliProfile } from './registry.js';
|
||||
|
||||
describe('detectCliProfile', () => {
|
||||
it('detects Codex responses requests and exposes Codex capability flags', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
originator: 'codex_cli_rs',
|
||||
Session_id: 'codex-session-123',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'codex',
|
||||
sessionId: 'codex-session-123',
|
||||
traceHint: 'codex-session-123',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: true,
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats x-codex-turn-state as a Codex marker even when session_id is absent', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/responses',
|
||||
headers: {
|
||||
'x-codex-turn-state': 'turn-state-123',
|
||||
},
|
||||
})).toMatchObject({
|
||||
id: 'codex',
|
||||
capabilities: {
|
||||
supportsResponsesWebsocketIncremental: true,
|
||||
echoesTurnState: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1/messages/count_tokens',
|
||||
body: {
|
||||
metadata: {
|
||||
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'claude_code',
|
||||
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: true,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Gemini CLI internal routes and exposes Gemini CLI capability flags', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1internal:countTokens',
|
||||
body: {
|
||||
model: 'gpt-4.1',
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'gemini_cli',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: true,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to generic for native Gemini routes', () => {
|
||||
expect(detectCliProfile({
|
||||
downstreamPath: '/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
body: {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'generic',
|
||||
capabilities: {
|
||||
supportsResponsesCompact: false,
|
||||
supportsResponsesWebsocketIncremental: false,
|
||||
preservesContinuation: false,
|
||||
supportsCountTokens: false,
|
||||
echoesTurnState: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { claudeCodeCliProfile } from './claudeCodeProfile.js';
|
||||
import { codexCliProfile } from './codexProfile.js';
|
||||
import { geminiCliProfile } from './geminiCliProfile.js';
|
||||
import { genericCliProfile } from './genericProfile.js';
|
||||
import type { CliProfileDefinition, CliProfileId, DetectCliProfileInput, DetectedCliProfile } from './types.js';
|
||||
|
||||
const orderedProfiles: CliProfileDefinition[] = [
|
||||
claudeCodeCliProfile,
|
||||
codexCliProfile,
|
||||
geminiCliProfile,
|
||||
];
|
||||
|
||||
export const cliProfileRegistry: Record<CliProfileId, CliProfileDefinition> = {
|
||||
generic: genericCliProfile,
|
||||
codex: codexCliProfile,
|
||||
claude_code: claudeCodeCliProfile,
|
||||
gemini_cli: geminiCliProfile,
|
||||
};
|
||||
|
||||
export function getCliProfileDefinition(id: CliProfileId): CliProfileDefinition {
|
||||
return cliProfileRegistry[id];
|
||||
}
|
||||
|
||||
export function detectCliProfile(input: DetectCliProfileInput): DetectedCliProfile {
|
||||
for (const profile of orderedProfiles) {
|
||||
const detected = profile.detect(input);
|
||||
if (!detected) continue;
|
||||
return {
|
||||
...detected,
|
||||
capabilities: profile.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: genericCliProfile.id,
|
||||
capabilities: genericCliProfile.capabilities,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type CliProfileId =
|
||||
| 'generic'
|
||||
| 'codex'
|
||||
| 'claude_code'
|
||||
| 'gemini_cli';
|
||||
|
||||
export type CliProfileCapabilities = {
|
||||
supportsResponsesCompact: boolean;
|
||||
supportsResponsesWebsocketIncremental: boolean;
|
||||
preservesContinuation: boolean;
|
||||
supportsCountTokens: boolean;
|
||||
echoesTurnState: boolean;
|
||||
};
|
||||
|
||||
export type DetectCliProfileInput = {
|
||||
downstreamPath: string;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
};
|
||||
|
||||
export type DetectedCliProfile = {
|
||||
id: CliProfileId;
|
||||
sessionId?: string;
|
||||
traceHint?: string;
|
||||
capabilities: CliProfileCapabilities;
|
||||
};
|
||||
|
||||
export type CliProfileDefinition = {
|
||||
id: CliProfileId;
|
||||
capabilities: CliProfileCapabilities;
|
||||
detect(input: DetectCliProfileInput): Omit<DetectedCliProfile, 'capabilities'> | null;
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DefaultProxyConductor } from './DefaultProxyConductor.js';
|
||||
import { terminalStreamFailure } from './streamTermination.js';
|
||||
|
||||
const baseSelectedChannel = {
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
|
||||
account: { id: 33, username: 'demo-user' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-demo',
|
||||
actualModel: 'upstream-gpt',
|
||||
};
|
||||
|
||||
describe('DefaultProxyConductor', () => {
|
||||
it('returns the first selected channel when the first attempt succeeds', async () => {
|
||||
const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel);
|
||||
const selectNextChannel = vi.fn();
|
||||
const recordSuccess = vi.fn().mockResolvedValue(undefined);
|
||||
const recordFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel,
|
||||
selectNextChannel,
|
||||
recordSuccess,
|
||||
recordFailure,
|
||||
});
|
||||
const attempt = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
latencyMs: 12,
|
||||
cost: 0.25,
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: baseSelectedChannel,
|
||||
attempts: 1,
|
||||
});
|
||||
expect(selectChannel).toHaveBeenCalledWith('gpt-5.4', undefined);
|
||||
expect(selectNextChannel).not.toHaveBeenCalled();
|
||||
expect(recordFailure).not.toHaveBeenCalled();
|
||||
expect(recordSuccess).toHaveBeenCalledWith(11, {
|
||||
latencyMs: 12,
|
||||
cost: 0.25,
|
||||
});
|
||||
});
|
||||
|
||||
it('retries on the same channel when the attempt asks for a same-channel retry', async () => {
|
||||
const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel);
|
||||
const selectNextChannel = vi.fn();
|
||||
const recordSuccess = vi.fn().mockResolvedValue(undefined);
|
||||
const recordFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel,
|
||||
selectNextChannel,
|
||||
recordSuccess,
|
||||
recordFailure,
|
||||
});
|
||||
const attempt = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
action: 'retry_same_channel',
|
||||
status: 429,
|
||||
rawErrorText: 'rate limited',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(selectNextChannel).not.toHaveBeenCalled();
|
||||
expect(attempt).toHaveBeenCalledTimes(2);
|
||||
expect(recordFailure).toHaveBeenCalledWith(11, {
|
||||
status: 429,
|
||||
rawErrorText: 'rate limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails over to the next channel when the attempt asks for failover', async () => {
|
||||
const nextSelectedChannel = {
|
||||
...baseSelectedChannel,
|
||||
channel: { id: 12, routeId: 22 },
|
||||
tokenValue: 'sk-next',
|
||||
};
|
||||
const selectChannel = vi.fn().mockResolvedValue(baseSelectedChannel);
|
||||
const selectNextChannel = vi.fn().mockResolvedValue(nextSelectedChannel);
|
||||
const recordSuccess = vi.fn().mockResolvedValue(undefined);
|
||||
const recordFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel,
|
||||
selectNextChannel,
|
||||
recordSuccess,
|
||||
recordFailure,
|
||||
});
|
||||
const attempt = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
action: 'failover',
|
||||
status: 503,
|
||||
rawErrorText: 'upstream unavailable',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: nextSelectedChannel,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(selectNextChannel).toHaveBeenCalledWith('gpt-5.4', [11], undefined);
|
||||
expect(recordFailure).toHaveBeenCalledWith(11, {
|
||||
status: 503,
|
||||
rawErrorText: 'upstream unavailable',
|
||||
});
|
||||
expect(recordSuccess).toHaveBeenCalledWith(12, {
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes auth on 401 and retries the same channel with the refreshed selection', async () => {
|
||||
const refreshedChannel = {
|
||||
...baseSelectedChannel,
|
||||
tokenValue: 'sk-refreshed',
|
||||
};
|
||||
const refreshAuth = vi.fn().mockResolvedValue(refreshedChannel);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel),
|
||||
selectNextChannel: vi.fn(),
|
||||
recordSuccess: vi.fn().mockResolvedValue(undefined),
|
||||
recordFailure: vi.fn().mockResolvedValue(undefined),
|
||||
refreshAuth,
|
||||
});
|
||||
const attempt = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
action: 'refresh_auth',
|
||||
status: 401,
|
||||
rawErrorText: 'expired token',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
response: new Response('ok', { status: 200 }),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
selected: refreshedChannel,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(refreshAuth).toHaveBeenCalledWith(baseSelectedChannel, {
|
||||
status: 401,
|
||||
rawErrorText: 'expired token',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a no_channel result when no channel is available', async () => {
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel: vi.fn().mockResolvedValue(null),
|
||||
selectNextChannel: vi.fn(),
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn(),
|
||||
previewSelectedChannel: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
expect(await conductor.previewSelectedChannel('gpt-5.4')).toBe(null);
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'no_channel',
|
||||
attempts: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates terminal stream failures and calls the terminal failure hook', async () => {
|
||||
const onTerminalFailure = vi.fn().mockResolvedValue(undefined);
|
||||
const conductor = new DefaultProxyConductor({
|
||||
selectChannel: vi.fn().mockResolvedValue(baseSelectedChannel),
|
||||
selectNextChannel: vi.fn(),
|
||||
recordSuccess: vi.fn(),
|
||||
recordFailure: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
const attempt = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
...terminalStreamFailure({
|
||||
status: 502,
|
||||
rawErrorText: 'stream disconnected before completion',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await conductor.execute({
|
||||
requestedModel: 'gpt-5.4',
|
||||
attempt,
|
||||
onTerminalFailure,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
reason: 'terminal',
|
||||
selected: baseSelectedChannel,
|
||||
status: 502,
|
||||
rawErrorText: 'stream disconnected before completion',
|
||||
attempts: 1,
|
||||
});
|
||||
expect(onTerminalFailure).toHaveBeenCalledWith(baseSelectedChannel, {
|
||||
status: 502,
|
||||
rawErrorText: 'stream disconnected before completion',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
failureActionOf,
|
||||
isTerminalFailure,
|
||||
shouldFailover,
|
||||
shouldRefreshAuth,
|
||||
shouldRetrySameChannel,
|
||||
} from './retryPolicy.js';
|
||||
import type { ExecuteInput, ExecuteResult, ProxyConductorDependencies, SelectedChannelLike } from './types.js';
|
||||
import { recordFailedAttempt, recordSuccessfulAttempt } from './usageHooks.js';
|
||||
|
||||
export class DefaultProxyConductor {
|
||||
constructor(private readonly deps: ProxyConductorDependencies) {}
|
||||
|
||||
async previewSelectedChannel(requestedModel: string, downstreamPolicy?: unknown): Promise<SelectedChannelLike | null> {
|
||||
if (this.deps.previewSelectedChannel) {
|
||||
return this.deps.previewSelectedChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
return this.deps.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput): Promise<ExecuteResult> {
|
||||
const excludeChannelIds: number[] = [];
|
||||
let attempts = 0;
|
||||
let selected = await this.deps.selectChannel(input.requestedModel, input.downstreamPolicy);
|
||||
if (!selected) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no_channel',
|
||||
attempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
while (selected) {
|
||||
const result = await input.attempt({
|
||||
selected,
|
||||
attemptIndex: attempts,
|
||||
excludeChannelIds: [...excludeChannelIds],
|
||||
});
|
||||
attempts += 1;
|
||||
|
||||
if (result.ok) {
|
||||
await recordSuccessfulAttempt(this.deps, selected.channel.id, {
|
||||
latencyMs: result.latencyMs ?? null,
|
||||
cost: result.cost ?? null,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
selected,
|
||||
response: result.response,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
const action = failureActionOf(result);
|
||||
await recordFailedAttempt(this.deps, selected.channel.id, {
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
});
|
||||
|
||||
if (isTerminalFailure(action)) {
|
||||
await input.onTerminalFailure?.(selected, {
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'terminal',
|
||||
selected,
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldRetrySameChannel(action)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shouldRefreshAuth(action) && this.deps.refreshAuth) {
|
||||
const refreshed = await this.deps.refreshAuth(selected, {
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
});
|
||||
if (refreshed) {
|
||||
selected = refreshed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFailover(action)) {
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
const next = await this.deps.selectNextChannel(
|
||||
input.requestedModel,
|
||||
excludeChannelIds,
|
||||
input.downstreamPolicy,
|
||||
);
|
||||
if (!next) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
selected,
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
selected = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
selected,
|
||||
status: result.status,
|
||||
rawErrorText: result.rawErrorText,
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'failed',
|
||||
attempts,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AttemptFailure, AttemptFailureAction } from './types.js';
|
||||
|
||||
export function failureActionOf(result: AttemptFailure): AttemptFailureAction {
|
||||
return result.action;
|
||||
}
|
||||
|
||||
export function shouldRetrySameChannel(action: AttemptFailureAction): boolean {
|
||||
return action === 'retry_same_channel';
|
||||
}
|
||||
|
||||
export function shouldRefreshAuth(action: AttemptFailureAction): boolean {
|
||||
return action === 'refresh_auth';
|
||||
}
|
||||
|
||||
export function shouldFailover(action: AttemptFailureAction): boolean {
|
||||
return action === 'failover' || action === 'refresh_auth';
|
||||
}
|
||||
|
||||
export function isTerminalFailure(action: AttemptFailureAction): boolean {
|
||||
return action === 'terminal';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function terminalStreamFailure(input: {
|
||||
status?: number;
|
||||
rawErrorText?: string;
|
||||
}) {
|
||||
return {
|
||||
action: 'terminal' as const,
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.rawErrorText ? { rawErrorText: input.rawErrorText } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export type SelectedChannelLike = {
|
||||
channel: { id: number; routeId?: number };
|
||||
site: Record<string, unknown>;
|
||||
account: Record<string, unknown>;
|
||||
tokenName?: string;
|
||||
tokenValue?: string;
|
||||
actualModel?: string;
|
||||
};
|
||||
|
||||
export type AttemptSuccess = {
|
||||
ok: true;
|
||||
response: Response;
|
||||
latencyMs?: number | null;
|
||||
cost?: number | null;
|
||||
};
|
||||
|
||||
export type AttemptFailureAction =
|
||||
| 'retry_same_channel'
|
||||
| 'refresh_auth'
|
||||
| 'failover'
|
||||
| 'terminal'
|
||||
| 'stop';
|
||||
|
||||
export type AttemptFailure = {
|
||||
ok: false;
|
||||
action: AttemptFailureAction;
|
||||
status?: number;
|
||||
rawErrorText?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type AttemptResult = AttemptSuccess | AttemptFailure;
|
||||
|
||||
export type ExecuteAttemptContext = {
|
||||
selected: SelectedChannelLike;
|
||||
attemptIndex: number;
|
||||
excludeChannelIds: number[];
|
||||
};
|
||||
|
||||
export type ProxyConductorDependencies = {
|
||||
selectChannel: (requestedModel: string, downstreamPolicy?: unknown) => Promise<SelectedChannelLike | null>;
|
||||
previewSelectedChannel?: (requestedModel: string, downstreamPolicy?: unknown) => Promise<SelectedChannelLike | null>;
|
||||
selectNextChannel: (
|
||||
requestedModel: string,
|
||||
excludeChannelIds: number[],
|
||||
downstreamPolicy?: unknown,
|
||||
) => Promise<SelectedChannelLike | null>;
|
||||
recordSuccess?: (channelId: number, metrics: { latencyMs: number | null; cost: number | null }) => Promise<void> | void;
|
||||
recordFailure?: (channelId: number, failure: { status?: number; rawErrorText?: string }) => Promise<void> | void;
|
||||
refreshAuth?: (
|
||||
selected: SelectedChannelLike,
|
||||
failure: { status?: number; rawErrorText?: string },
|
||||
) => Promise<SelectedChannelLike | null>;
|
||||
};
|
||||
|
||||
export type ExecuteInput = {
|
||||
requestedModel: string;
|
||||
downstreamPolicy?: unknown;
|
||||
attempt: (context: ExecuteAttemptContext) => Promise<AttemptResult>;
|
||||
onTerminalFailure?: (
|
||||
selected: SelectedChannelLike,
|
||||
failure: { status?: number; rawErrorText?: string },
|
||||
) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export type ExecuteResult =
|
||||
| {
|
||||
ok: true;
|
||||
selected: SelectedChannelLike;
|
||||
response: Response;
|
||||
attempts: number;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'no_channel' | 'failed' | 'terminal';
|
||||
selected?: SelectedChannelLike;
|
||||
status?: number;
|
||||
rawErrorText?: string;
|
||||
attempts: number;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ProxyConductorDependencies } from './types.js';
|
||||
|
||||
export async function recordSuccessfulAttempt(
|
||||
deps: ProxyConductorDependencies,
|
||||
channelId: number,
|
||||
metrics: { latencyMs?: number | null; cost?: number | null },
|
||||
): Promise<void> {
|
||||
await deps.recordSuccess?.(channelId, {
|
||||
latencyMs: metrics.latencyMs ?? null,
|
||||
cost: metrics.cost ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordFailedAttempt(
|
||||
deps: ProxyConductorDependencies,
|
||||
channelId: number,
|
||||
failure: { status?: number; rawErrorText?: string },
|
||||
): Promise<void> {
|
||||
await deps.recordFailure?.(channelId, failure);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Response } from 'undici';
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js';
|
||||
import {
|
||||
asTrimmedString,
|
||||
materializeErrorResponse,
|
||||
performFetch,
|
||||
withRequestBody,
|
||||
} from './types.js';
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_BASE_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
] as const;
|
||||
|
||||
function antigravityRequestType(modelName: string): 'image_gen' | 'agent' {
|
||||
return modelName.includes('image') ? 'image_gen' : 'agent';
|
||||
}
|
||||
|
||||
function generateAntigravityProjectId(): string {
|
||||
const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold'];
|
||||
const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge'];
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful';
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal';
|
||||
const suffix = Math.floor(100000 + Math.random() * 900000);
|
||||
return `${adjective}-${noun}-${suffix}`;
|
||||
}
|
||||
|
||||
function extractFirstUserText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return '';
|
||||
for (const content of value) {
|
||||
if (!content || typeof content !== 'object' || Array.isArray(content)) continue;
|
||||
const record = content as Record<string, unknown>;
|
||||
if (asTrimmedString(record.role) !== 'user') continue;
|
||||
const parts = Array.isArray(record.parts) ? record.parts : [];
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== 'object' || Array.isArray(part)) continue;
|
||||
const text = asTrimmedString((part as Record<string, unknown>).text);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function generateStableAntigravitySessionId(payload: Record<string, unknown>): string {
|
||||
const firstUserText = extractFirstUserText(
|
||||
payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request)
|
||||
? (payload.request as Record<string, unknown>).contents
|
||||
: undefined,
|
||||
);
|
||||
if (!firstUserText) {
|
||||
return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`;
|
||||
}
|
||||
const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16);
|
||||
const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff');
|
||||
return `-${bigint.toString()}`;
|
||||
}
|
||||
|
||||
function renameParametersJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => renameParametersJsonSchema(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(input)) {
|
||||
const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key;
|
||||
output[nextKey] = renameParametersJsonSchema(entry);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function deleteNestedMaxOutputTokens(payload: Record<string, unknown>): void {
|
||||
const request = payload.request;
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) return;
|
||||
const generationConfig = (request as Record<string, unknown>).generationConfig;
|
||||
if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return;
|
||||
delete (generationConfig as Record<string, unknown>).maxOutputTokens;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeBody(
|
||||
originalBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
action?: NonNullable<RuntimeDispatchInput['request']['runtime']>['action'],
|
||||
): Record<string, unknown> {
|
||||
const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record<string, unknown>;
|
||||
if (action === 'countTokens') {
|
||||
return payload;
|
||||
}
|
||||
const requestType = antigravityRequestType(modelName);
|
||||
const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId();
|
||||
|
||||
payload.model = modelName;
|
||||
payload.project = projectId;
|
||||
payload.userAgent = 'antigravity';
|
||||
payload.requestType = requestType;
|
||||
payload.requestId = requestType === 'image_gen'
|
||||
? `image_gen/${Date.now()}/${randomUUID()}/12`
|
||||
: `agent-${randomUUID()}`;
|
||||
|
||||
const request = payload.request;
|
||||
if (request && typeof request === 'object' && !Array.isArray(request)) {
|
||||
delete (request as Record<string, unknown>).safetySettings;
|
||||
if (requestType !== 'image_gen') {
|
||||
(request as Record<string, unknown>).sessionId = generateStableAntigravitySessionId(payload);
|
||||
}
|
||||
if (modelName.includes('claude')) {
|
||||
const toolConfig = (
|
||||
(request as Record<string, unknown>).toolConfig
|
||||
&& typeof (request as Record<string, unknown>).toolConfig === 'object'
|
||||
&& !Array.isArray((request as Record<string, unknown>).toolConfig)
|
||||
)
|
||||
? (request as Record<string, unknown>).toolConfig as Record<string, unknown>
|
||||
: (((request as Record<string, unknown>).toolConfig = {}) as Record<string, unknown>);
|
||||
toolConfig.functionCallingConfig = { mode: 'VALIDATED' };
|
||||
} else {
|
||||
deleteNestedMaxOutputTokens(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function antigravityShouldRetryNoCapacity(
|
||||
status: number,
|
||||
responseText: string,
|
||||
): boolean {
|
||||
return status === 503 && responseText.toLowerCase().includes('no capacity available');
|
||||
}
|
||||
|
||||
function antigravityNoCapacityRetryDelay(attempt: number): number {
|
||||
return Math.min((attempt + 1) * 250, 2000);
|
||||
}
|
||||
|
||||
async function waitMs(ms: number): Promise<void> {
|
||||
if (ms <= 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const antigravityExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const runtimeBody = buildAntigravityRuntimeBody(
|
||||
input.request.body,
|
||||
modelName,
|
||||
input.request.runtime?.action,
|
||||
);
|
||||
const baseAttempts = 3;
|
||||
let lastResponse: RuntimeResponse | null = null;
|
||||
|
||||
attemptLoop:
|
||||
for (let attempt = 0; attempt < baseAttempts; attempt += 1) {
|
||||
for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) {
|
||||
const requestUrl = `${baseUrl}${input.request.path}`;
|
||||
const minimalHeaders: Record<string, string> = {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
};
|
||||
let response: RuntimeResponse;
|
||||
try {
|
||||
response = await performFetch(
|
||||
input,
|
||||
withRequestBody(input.request, runtimeBody, minimalHeaders),
|
||||
requestUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
continue attemptLoop;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) return response;
|
||||
|
||||
const errorResponse = await materializeErrorResponse(response);
|
||||
const errorText = await errorResponse.text().catch(() => '');
|
||||
lastResponse = new Response(errorText, {
|
||||
status: errorResponse.status,
|
||||
headers: errorResponse.headers,
|
||||
});
|
||||
|
||||
if (errorResponse.status === 429) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
await waitMs(antigravityNoCapacityRetryDelay(attempt));
|
||||
continue attemptLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js';
|
||||
import { performFetch } from './types.js';
|
||||
|
||||
export const claudeExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
return performFetch(input, input.request);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor } from './types.js';
|
||||
import { performFetch } from './types.js';
|
||||
|
||||
export const codexExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
return performFetch(input, input.request);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { RuntimeDispatchInput, RuntimeExecutor, RuntimeResponse } from './types.js';
|
||||
import {
|
||||
asTrimmedString,
|
||||
materializeErrorResponse,
|
||||
performFetch,
|
||||
withRequestBody,
|
||||
} from './types.js';
|
||||
|
||||
function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined {
|
||||
const raw = asTrimmedString(userAgent);
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `);
|
||||
}
|
||||
|
||||
function buildGeminiCliAttemptRequest(
|
||||
request: RuntimeDispatchInput['request'],
|
||||
modelName: string,
|
||||
) {
|
||||
const body = structuredClone(request.body);
|
||||
const action = request.runtime?.action;
|
||||
if (action === 'countTokens') {
|
||||
delete body.model;
|
||||
delete body.project;
|
||||
} else {
|
||||
body.model = modelName;
|
||||
}
|
||||
const headers = { ...request.headers };
|
||||
const nextUserAgent = replaceGeminiCliModelInUserAgent(
|
||||
headers['User-Agent'] || headers['user-agent'],
|
||||
modelName,
|
||||
);
|
||||
if (nextUserAgent) {
|
||||
headers['User-Agent'] = nextUserAgent;
|
||||
delete headers['user-agent'];
|
||||
}
|
||||
return withRequestBody(request, body, headers);
|
||||
}
|
||||
|
||||
export const geminiCliExecutor: RuntimeExecutor = {
|
||||
async dispatch(input: RuntimeDispatchInput) {
|
||||
const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const models = [baseModel].filter(Boolean);
|
||||
let lastResponse: RuntimeResponse | null = null;
|
||||
|
||||
for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) {
|
||||
const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName);
|
||||
const response = await performFetch(input, attemptRequest);
|
||||
if (response.ok) return response;
|
||||
|
||||
if (response.status === 429) {
|
||||
lastResponse = await materializeErrorResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
return materializeErrorResponse(response);
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, input.request);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Response,
|
||||
fetch,
|
||||
type RequestInit as UndiciRequestInit,
|
||||
type Response as UndiciResponse,
|
||||
} from 'undici';
|
||||
|
||||
export type ProxyRuntimeRequest = {
|
||||
endpoint: 'chat' | 'messages' | 'responses';
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime?: {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
};
|
||||
};
|
||||
|
||||
export type RuntimeDispatchInput = {
|
||||
siteUrl: string;
|
||||
request: ProxyRuntimeRequest;
|
||||
targetUrl?: string;
|
||||
buildInit: (requestUrl: string, request: ProxyRuntimeRequest) => Promise<UndiciRequestInit> | UndiciRequestInit;
|
||||
};
|
||||
|
||||
export type RuntimeResponse = UndiciResponse;
|
||||
|
||||
export type RuntimeExecutor = {
|
||||
dispatch(input: RuntimeDispatchInput): Promise<RuntimeResponse>;
|
||||
};
|
||||
|
||||
export function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function withRequestBody(
|
||||
request: ProxyRuntimeRequest,
|
||||
body: Record<string, unknown>,
|
||||
headers?: Record<string, string>,
|
||||
): ProxyRuntimeRequest {
|
||||
return {
|
||||
...request,
|
||||
headers: headers ? { ...headers } : { ...request.headers },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpstreamUrl(siteUrl: string, path: string): string {
|
||||
const normalizedBase = siteUrl.replace(/\/+$/, '');
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export async function performFetch(
|
||||
input: RuntimeDispatchInput,
|
||||
request: ProxyRuntimeRequest,
|
||||
requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path),
|
||||
): Promise<RuntimeResponse> {
|
||||
const init = await input.buildInit(requestUrl, request);
|
||||
return fetch(requestUrl, init);
|
||||
}
|
||||
|
||||
export async function materializeErrorResponse(
|
||||
response: RuntimeResponse,
|
||||
): Promise<RuntimeResponse> {
|
||||
if (response.ok) return response;
|
||||
const text = await response.text().catch(() => '');
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js';
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction {
|
||||
if (action) return action;
|
||||
return stream ? 'streamGenerateContent' : 'generateContent';
|
||||
}
|
||||
|
||||
function resolvePath(action: ProviderAction): string {
|
||||
if (action === 'countTokens') return '/v1internal:countTokens';
|
||||
if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse';
|
||||
return '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
export const antigravityProviderProfile: ProviderProfile = {
|
||||
id: 'antigravity',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const action = resolveAction(input.action, input.stream);
|
||||
const projectId = asTrimmedString(input.oauthProjectId);
|
||||
return {
|
||||
path: resolvePath(action),
|
||||
headers: {
|
||||
Authorization: input.baseHeaders.Authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: action === 'streamGenerateContent' ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT,
|
||||
},
|
||||
body: {
|
||||
project: projectId,
|
||||
model: input.modelName,
|
||||
request: input.body,
|
||||
},
|
||||
runtime: {
|
||||
executor: 'antigravity',
|
||||
modelName: input.modelName,
|
||||
stream: action === 'streamGenerateContent',
|
||||
oauthProjectId: projectId,
|
||||
action,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||
|
||||
const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)';
|
||||
const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05';
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInputHeader(
|
||||
headers: Record<string, unknown> | Record<string, string> | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!headers) return null;
|
||||
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
|
||||
if (candidateKey.toLowerCase() !== key.toLowerCase()) continue;
|
||||
return headerValueToString(candidateValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeClaudeBetaHeader(
|
||||
explicitValue: string | null,
|
||||
extraBetas: string[] = [],
|
||||
): string {
|
||||
const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER;
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const entry of source.split(',')) {
|
||||
const normalized = entry.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
merged.push(normalized);
|
||||
}
|
||||
if (!explicitValue) {
|
||||
for (const entry of extraBetas) {
|
||||
const normalized = entry.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
merged.push(normalized);
|
||||
}
|
||||
}
|
||||
return merged.join(',');
|
||||
}
|
||||
|
||||
function buildClaudeRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
claudeHeaders: Record<string, string>;
|
||||
anthropicVersion: string;
|
||||
stream: boolean;
|
||||
isClaudeOauthUpstream: boolean;
|
||||
tokenValue: string;
|
||||
}): Record<string, string> {
|
||||
const anthropicBeta = mergeClaudeBetaHeader(
|
||||
getInputHeader(input.claudeHeaders, 'anthropic-beta'),
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
...input.baseHeaders,
|
||||
...input.claudeHeaders,
|
||||
'anthropic-version': input.anthropicVersion,
|
||||
...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}),
|
||||
'Anthropic-Dangerous-Direct-Browser-Access': 'true',
|
||||
'X-App': 'cli',
|
||||
'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0',
|
||||
'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0',
|
||||
'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0',
|
||||
'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node',
|
||||
'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js',
|
||||
'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64',
|
||||
'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows',
|
||||
'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600',
|
||||
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
|
||||
Connection: 'keep-alive',
|
||||
Accept: input.stream ? 'text/event-stream' : 'application/json',
|
||||
'Accept-Encoding': input.stream ? 'identity' : 'gzip, deflate, br, zstd',
|
||||
};
|
||||
if (input.isClaudeOauthUpstream) {
|
||||
headers.Authorization = `Bearer ${input.tokenValue}`;
|
||||
} else {
|
||||
headers['x-api-key'] = input.tokenValue;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const claudeProviderProfile: ProviderProfile = {
|
||||
id: 'claude',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const anthropicVersion = (
|
||||
input.claudeHeaders?.['anthropic-version']
|
||||
|| input.baseHeaders['anthropic-version']
|
||||
|| '2023-06-01'
|
||||
);
|
||||
const isClaudeOauthUpstream = input.sitePlatform?.trim().toLowerCase() === 'claude'
|
||||
&& input.oauthProvider === 'claude';
|
||||
const isCountTokens = input.action === 'countTokens';
|
||||
|
||||
return {
|
||||
path: isCountTokens ? '/v1/messages/count_tokens?beta=true' : '/v1/messages',
|
||||
headers: buildClaudeRuntimeHeaders({
|
||||
baseHeaders: input.baseHeaders,
|
||||
claudeHeaders: input.claudeHeaders ?? {},
|
||||
anthropicVersion,
|
||||
stream: isCountTokens ? false : input.stream,
|
||||
isClaudeOauthUpstream,
|
||||
tokenValue: input.tokenValue,
|
||||
}),
|
||||
body: input.body,
|
||||
runtime: {
|
||||
executor: 'claude',
|
||||
modelName: input.modelName,
|
||||
stream: isCountTokens ? false : input.stream,
|
||||
oauthProjectId: null,
|
||||
...(isCountTokens ? { action: 'countTokens' } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||
|
||||
const CODEX_CLIENT_VERSION = '0.101.0';
|
||||
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInputHeader(
|
||||
headers: Record<string, unknown> | Record<string, string> | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!headers) return null;
|
||||
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
|
||||
if (candidateKey.toLowerCase() !== key.toLowerCase()) continue;
|
||||
return headerValueToString(candidateValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function uuidFromSeed(seed: string): string {
|
||||
const hash = createHash('sha1').update(seed).digest();
|
||||
const bytes = new Uint8Array(hash.subarray(0, 16));
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function buildCodexRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
explicitSessionId?: string | null;
|
||||
continuityKey?: string | null;
|
||||
}): Record<string, string> {
|
||||
const authorization = (
|
||||
getInputHeader(input.baseHeaders, 'authorization')
|
||||
|| getInputHeader(input.baseHeaders, 'Authorization')
|
||||
|| ''
|
||||
);
|
||||
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
|
||||
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
|
||||
const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION;
|
||||
const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
|
||||
const explicitSessionId = asTrimmedString(input.explicitSessionId);
|
||||
const continuityKey = asTrimmedString(input.continuityKey);
|
||||
const sessionId = (
|
||||
getInputHeader(input.baseHeaders, 'session_id')
|
||||
|| getInputHeader(input.baseHeaders, 'session-id')
|
||||
|| explicitSessionId
|
||||
|| (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null)
|
||||
|| randomUUID()
|
||||
);
|
||||
const conversationId = (
|
||||
getInputHeader(input.baseHeaders, 'conversation_id')
|
||||
|| getInputHeader(input.baseHeaders, 'conversation-id')
|
||||
|| explicitSessionId
|
||||
|| (continuityKey ? sessionId : null)
|
||||
);
|
||||
|
||||
return {
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
|
||||
Originator: originator,
|
||||
Version: version,
|
||||
Session_id: sessionId,
|
||||
...(conversationId ? { Conversation_id: conversationId } : {}),
|
||||
'User-Agent': userAgent,
|
||||
Accept: 'text/event-stream',
|
||||
Connection: 'Keep-Alive',
|
||||
};
|
||||
}
|
||||
|
||||
export const codexProviderProfile: ProviderProfile = {
|
||||
id: 'codex',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const headers = buildCodexRuntimeHeaders({
|
||||
baseHeaders: input.baseHeaders,
|
||||
providerHeaders: input.providerHeaders,
|
||||
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
||||
});
|
||||
const codexSessionId = getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id');
|
||||
const shouldInjectDerivedPromptCacheKey = !!codexSessionId
|
||||
&& !asTrimmedString(input.body.prompt_cache_key)
|
||||
&& !asTrimmedString(input.codexExplicitSessionId)
|
||||
&& !!asTrimmedString(input.codexSessionCacheKey);
|
||||
const body = shouldInjectDerivedPromptCacheKey
|
||||
? {
|
||||
...input.body,
|
||||
prompt_cache_key: codexSessionId,
|
||||
}
|
||||
: input.body;
|
||||
|
||||
return {
|
||||
path: '/responses',
|
||||
headers,
|
||||
body,
|
||||
runtime: {
|
||||
executor: 'codex',
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderAction, ProviderProfile } from './types.js';
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getInputHeader(
|
||||
headers: Record<string, unknown> | Record<string, string> | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!headers) return null;
|
||||
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
|
||||
if (candidateKey.toLowerCase() !== key.toLowerCase()) continue;
|
||||
return headerValueToString(candidateValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseGeminiCliUserAgentRuntime(userAgent: string | null): {
|
||||
version: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
} | null {
|
||||
if (!userAgent) return null;
|
||||
const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
version: match[1] || '0.31.0',
|
||||
platform: match[2] || 'win32',
|
||||
arch: match[3] || 'x64',
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string {
|
||||
const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null);
|
||||
const version = parsed?.version || '0.31.0';
|
||||
const platform = parsed?.platform || 'win32';
|
||||
const arch = parsed?.arch || 'x64';
|
||||
const effectiveModel = asTrimmedString(modelName) || 'unknown';
|
||||
return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`;
|
||||
}
|
||||
|
||||
function resolveAction(action: ProviderAction | undefined, stream: boolean): ProviderAction {
|
||||
if (action) return action;
|
||||
return stream ? 'streamGenerateContent' : 'generateContent';
|
||||
}
|
||||
|
||||
function resolvePath(action: ProviderAction): string {
|
||||
if (action === 'countTokens') return '/v1internal:countTokens';
|
||||
if (action === 'streamGenerateContent') return '/v1internal:streamGenerateContent?alt=sse';
|
||||
return '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
export const geminiCliProviderProfile: ProviderProfile = {
|
||||
id: 'gemini-cli',
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest {
|
||||
const projectId = asTrimmedString(input.oauthProjectId);
|
||||
if (!projectId) {
|
||||
throw new Error('gemini-cli oauth project id missing');
|
||||
}
|
||||
const action = resolveAction(input.action, input.stream);
|
||||
const apiClient = (
|
||||
getInputHeader(input.providerHeaders, 'x-goog-api-client')
|
||||
|| getInputHeader(input.baseHeaders, 'x-goog-api-client')
|
||||
);
|
||||
const userAgent = buildGeminiCLIUserAgent(
|
||||
input.modelName,
|
||||
getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'),
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: input.baseHeaders.Authorization,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
};
|
||||
if (apiClient) {
|
||||
headers['X-Goog-Api-Client'] = apiClient;
|
||||
}
|
||||
if (action === 'streamGenerateContent') {
|
||||
headers.Accept = 'text/event-stream';
|
||||
}
|
||||
return {
|
||||
path: resolvePath(action),
|
||||
headers,
|
||||
body: {
|
||||
project: projectId,
|
||||
model: input.modelName,
|
||||
request: input.body,
|
||||
},
|
||||
runtime: {
|
||||
executor: 'gemini-cli',
|
||||
modelName: input.modelName,
|
||||
stream: action === 'streamGenerateContent',
|
||||
oauthProjectId: projectId,
|
||||
action,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveProviderProfile } from './registry.js';
|
||||
|
||||
describe('resolveProviderProfile', () => {
|
||||
it('builds codex provider requests with codex-specific path, headers, and runtime metadata', () => {
|
||||
const profile = resolveProviderProfile('codex');
|
||||
expect(profile?.id).toBe('codex');
|
||||
|
||||
const result = profile!.prepareRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.2-codex',
|
||||
stream: true,
|
||||
tokenValue: 'oauth-access-token',
|
||||
sitePlatform: 'codex',
|
||||
baseHeaders: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
},
|
||||
providerHeaders: {
|
||||
Originator: 'codex_cli_rs',
|
||||
'Chatgpt-Account-Id': 'chatgpt-account-123',
|
||||
},
|
||||
codexSessionCacheKey: 'gpt-5.2-codex:user-123',
|
||||
body: {
|
||||
model: 'gpt-5.2-codex',
|
||||
stream: true,
|
||||
store: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.path).toBe('/responses');
|
||||
expect(result.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(result.headers.Originator).toBe('codex_cli_rs');
|
||||
expect(result.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
|
||||
expect(result.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(result.headers.Conversation_id).toBe(result.headers.Session_id);
|
||||
expect(result.runtime).toMatchObject({
|
||||
executor: 'codex',
|
||||
modelName: 'gpt-5.2-codex',
|
||||
stream: true,
|
||||
});
|
||||
expect(result.body.prompt_cache_key).toBe(result.headers.Session_id);
|
||||
});
|
||||
|
||||
it('builds claude provider requests without rebuilding protocol bodies', () => {
|
||||
const profile = resolveProviderProfile('claude');
|
||||
expect(profile?.id).toBe('claude');
|
||||
|
||||
const protocolBody = {
|
||||
model: 'claude-opus-4-6',
|
||||
max_tokens: 256,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
};
|
||||
|
||||
const result = profile!.prepareRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
tokenValue: 'oauth-access-token',
|
||||
oauthProvider: 'claude',
|
||||
sitePlatform: 'claude',
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
claudeHeaders: {},
|
||||
body: protocolBody,
|
||||
});
|
||||
|
||||
expect(result.path).toBe('/v1/messages');
|
||||
expect(result.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(result.headers['x-api-key']).toBeUndefined();
|
||||
expect(result.headers['anthropic-version']).toBe('2023-06-01');
|
||||
expect(result.runtime).toMatchObject({
|
||||
executor: 'claude',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: false,
|
||||
});
|
||||
expect(result.body).toBe(protocolBody);
|
||||
});
|
||||
|
||||
it('builds gemini-cli provider requests with wrapped runtime envelope and project validation', () => {
|
||||
const profile = resolveProviderProfile('gemini-cli');
|
||||
expect(profile?.id).toBe('gemini-cli');
|
||||
|
||||
const protocolBody = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello gemini cli' }] }],
|
||||
};
|
||||
|
||||
const result = profile!.prepareRequest({
|
||||
endpoint: 'chat',
|
||||
modelName: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
tokenValue: 'oauth-access-token',
|
||||
oauthProvider: 'gemini-cli',
|
||||
oauthProjectId: 'project-demo',
|
||||
sitePlatform: 'gemini-cli',
|
||||
baseHeaders: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
},
|
||||
providerHeaders: {
|
||||
'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)',
|
||||
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
|
||||
},
|
||||
body: protocolBody,
|
||||
});
|
||||
|
||||
expect(result.path).toBe('/v1internal:generateContent');
|
||||
expect(result.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(result.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)');
|
||||
expect(result.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/');
|
||||
expect(result.runtime).toMatchObject({
|
||||
executor: 'gemini-cli',
|
||||
modelName: 'gemini-2.5-pro',
|
||||
oauthProjectId: 'project-demo',
|
||||
action: 'generateContent',
|
||||
});
|
||||
expect(result.body).toEqual({
|
||||
project: 'project-demo',
|
||||
model: 'gemini-2.5-pro',
|
||||
request: protocolBody,
|
||||
});
|
||||
expect((result.body as Record<string, unknown>).request).toBe(protocolBody);
|
||||
|
||||
expect(() => profile!.prepareRequest({
|
||||
endpoint: 'chat',
|
||||
modelName: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
tokenValue: 'oauth-access-token',
|
||||
oauthProvider: 'gemini-cli',
|
||||
sitePlatform: 'gemini-cli',
|
||||
baseHeaders: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
},
|
||||
body: protocolBody,
|
||||
})).toThrow(/project id missing/i);
|
||||
});
|
||||
|
||||
it('builds antigravity provider requests without leaking gemini-cli client headers', () => {
|
||||
const profile = resolveProviderProfile('antigravity');
|
||||
expect(profile?.id).toBe('antigravity');
|
||||
|
||||
const protocolBody = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello antigravity' }] }],
|
||||
};
|
||||
|
||||
const result = profile!.prepareRequest({
|
||||
endpoint: 'chat',
|
||||
modelName: 'gemini-3-pro-preview',
|
||||
stream: true,
|
||||
tokenValue: 'oauth-access-token',
|
||||
oauthProjectId: 'project-demo',
|
||||
sitePlatform: 'antigravity',
|
||||
baseHeaders: {
|
||||
Authorization: 'Bearer oauth-access-token',
|
||||
},
|
||||
providerHeaders: {
|
||||
'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)',
|
||||
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
|
||||
},
|
||||
body: protocolBody,
|
||||
});
|
||||
|
||||
expect(result.path).toBe('/v1internal:streamGenerateContent?alt=sse');
|
||||
expect(result.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(result.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64');
|
||||
expect(result.headers['X-Goog-Api-Client']).toBeUndefined();
|
||||
expect(result.headers.Accept).toBe('text/event-stream');
|
||||
expect(result.runtime).toMatchObject({
|
||||
executor: 'antigravity',
|
||||
modelName: 'gemini-3-pro-preview',
|
||||
oauthProjectId: 'project-demo',
|
||||
action: 'streamGenerateContent',
|
||||
});
|
||||
expect(result.body).toEqual({
|
||||
project: 'project-demo',
|
||||
model: 'gemini-3-pro-preview',
|
||||
request: protocolBody,
|
||||
});
|
||||
expect((result.body as Record<string, unknown>).request).toBe(protocolBody);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { antigravityProviderProfile } from './antigravityProviderProfile.js';
|
||||
import { claudeProviderProfile } from './claudeProviderProfile.js';
|
||||
import { codexProviderProfile } from './codexProviderProfile.js';
|
||||
import { geminiCliProviderProfile } from './geminiCliProviderProfile.js';
|
||||
import type { ProviderProfile } from './types.js';
|
||||
|
||||
const providerProfilesByPlatform: Record<string, ProviderProfile> = {
|
||||
codex: codexProviderProfile,
|
||||
claude: claudeProviderProfile,
|
||||
'gemini-cli': geminiCliProviderProfile,
|
||||
antigravity: antigravityProviderProfile,
|
||||
};
|
||||
|
||||
export function resolveProviderProfile(sitePlatform?: string | null): ProviderProfile | null {
|
||||
const normalized = typeof sitePlatform === 'string' ? sitePlatform.trim().toLowerCase() : '';
|
||||
return providerProfilesByPlatform[normalized] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export type ProviderProfileId =
|
||||
| 'codex'
|
||||
| 'claude'
|
||||
| 'gemini-cli'
|
||||
| 'antigravity';
|
||||
|
||||
export type ProviderEndpoint =
|
||||
| 'chat'
|
||||
| 'messages'
|
||||
| 'responses';
|
||||
|
||||
export type ProviderAction =
|
||||
| 'generateContent'
|
||||
| 'streamGenerateContent'
|
||||
| 'countTokens';
|
||||
|
||||
export type ProviderRuntimeDescriptor = {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: ProviderAction;
|
||||
};
|
||||
|
||||
export type PreparedProviderRequest = {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime: ProviderRuntimeDescriptor;
|
||||
};
|
||||
|
||||
export type PrepareProviderRequestInput = {
|
||||
endpoint: ProviderEndpoint;
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
tokenValue: string;
|
||||
oauthProvider?: string;
|
||||
oauthProjectId?: string;
|
||||
sitePlatform?: string;
|
||||
baseHeaders: Record<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
claudeHeaders?: Record<string, string>;
|
||||
codexSessionCacheKey?: string | null;
|
||||
codexExplicitSessionId?: string | null;
|
||||
body: Record<string, unknown>;
|
||||
action?: ProviderAction;
|
||||
};
|
||||
|
||||
export type ProviderProfile = {
|
||||
id: ProviderProfileId;
|
||||
prepareRequest(input: PrepareProviderRequestInput): PreparedProviderRequest;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
getProxyFileByPublicIdForOwner,
|
||||
getProxyFileContentByPublicIdForOwner,
|
||||
listProxyFilesByOwner,
|
||||
saveProxyFile,
|
||||
softDeleteProxyFileByPublicIdForOwner,
|
||||
} from '../../services/proxyFileStore.js';
|
||||
import { ensureMultipartBufferParser, parseMultipartFormData } from '../../routes/proxy/multipart.js';
|
||||
|
||||
function invalidRequest(reply: FastifyReply, message: string) {
|
||||
return reply.code(400).send({ error: { message, type: 'invalid_request_error' } });
|
||||
}
|
||||
|
||||
function notFound(reply: FastifyReply, message = 'file not found') {
|
||||
return reply.code(404).send({ error: { message, type: 'not_found_error' } });
|
||||
}
|
||||
|
||||
function toUnixSeconds(sqlDateTime: string | null | undefined): number {
|
||||
if (!sqlDateTime) return Math.floor(Date.now() / 1000);
|
||||
const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z');
|
||||
if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000);
|
||||
return Math.floor(parsed / 1000);
|
||||
}
|
||||
|
||||
function toFileObject(record: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
purpose: string | null;
|
||||
byteSize: number;
|
||||
createdAt: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: record.publicId,
|
||||
object: 'file',
|
||||
bytes: record.byteSize,
|
||||
created_at: toUnixSeconds(record.createdAt),
|
||||
filename: record.filename,
|
||||
purpose: record.purpose || 'assistants',
|
||||
mime_type: record.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function inferMimeTypeFromFilename(filename: string): string {
|
||||
const normalized = filename.trim().toLowerCase();
|
||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
||||
if (normalized.endsWith('.json')) return 'application/json';
|
||||
if (normalized.endsWith('.png')) return 'image/png';
|
||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedProxyFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export async function filesProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
|
||||
const formData = await parseMultipartFormData(request);
|
||||
if (!formData) {
|
||||
return invalidRequest(reply, 'multipart/form-data with a file field is required');
|
||||
}
|
||||
|
||||
const fileEntry = formData.get('file');
|
||||
if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') {
|
||||
return invalidRequest(reply, 'file field is required');
|
||||
}
|
||||
|
||||
const filename = fileEntry.name || 'upload.bin';
|
||||
const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase();
|
||||
if (!isSupportedProxyFileMimeType(mimeType)) {
|
||||
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
|
||||
const purposeValue = formData.get('purpose');
|
||||
const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0
|
||||
? purposeValue.trim()
|
||||
: 'assistants';
|
||||
const buffer = Buffer.from(await fileEntry.arrayBuffer());
|
||||
|
||||
const saved = await saveProxyFile({
|
||||
...owner,
|
||||
filename,
|
||||
mimeType,
|
||||
purpose,
|
||||
contentBase64: buffer.toString('base64'),
|
||||
});
|
||||
return reply.send(toFileObject(saved));
|
||||
});
|
||||
|
||||
app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const files = await listProxyFilesByOwner(owner);
|
||||
return reply.send({
|
||||
object: 'list',
|
||||
data: files.map((item) => toFileObject(item)),
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!file) return notFound(reply);
|
||||
return reply.send(toFileObject(file));
|
||||
});
|
||||
|
||||
app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!file) return notFound(reply);
|
||||
reply.type(file.mimeType);
|
||||
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`);
|
||||
return reply.send(file.buffer);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!deleted) return notFound(reply);
|
||||
return reply.send({
|
||||
id: request.params.fileId,
|
||||
object: 'file',
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,843 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { fetch } from 'undici';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||
import {
|
||||
buildUpstreamEndpointRequest,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||
import {
|
||||
geminiGenerateContentTransformer,
|
||||
} from '../../transformers/gemini/generate-content/index.js';
|
||||
import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js';
|
||||
import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
wrapGeminiCliRequest,
|
||||
} from '../../routes/proxy/geminiCliCompat.js';
|
||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const GEMINI_MODEL_PROBES = [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-1.5-flash',
|
||||
'gemini-pro',
|
||||
];
|
||||
const GEMINI_CLI_STATIC_MODELS = [
|
||||
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
|
||||
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
|
||||
{ name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' },
|
||||
{ name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' },
|
||||
{ name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
|
||||
{ name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' },
|
||||
{ name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' },
|
||||
];
|
||||
const EMPTY_PROXY_USAGE = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
function isGeminiCliPlatform(platform: unknown): boolean {
|
||||
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
|
||||
}
|
||||
|
||||
function isAntigravityPlatform(platform: unknown): boolean {
|
||||
return String(platform || '').trim().toLowerCase() === 'antigravity';
|
||||
}
|
||||
|
||||
function isInternalGeminiPlatform(platform: unknown): boolean {
|
||||
return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform);
|
||||
}
|
||||
|
||||
function buildGeminiCliActionPath(input: {
|
||||
isStreamAction: boolean;
|
||||
isCountTokensAction: boolean;
|
||||
}) {
|
||||
if (input.isCountTokensAction) return '/v1internal:countTokens';
|
||||
if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse';
|
||||
return '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
function isDirectGeminiFamilyPlatform(platform: unknown): boolean {
|
||||
const normalized = String(platform || '').trim().toLowerCase();
|
||||
return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity';
|
||||
}
|
||||
|
||||
function omitGeminiCliModelField(body: unknown): Record<string, unknown> {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return {};
|
||||
const { model: _model, ...rest } = body as Record<string, unknown>;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function selectGeminiChannel(request: FastifyRequest) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
const selected = await tokenRouter.selectChannel(candidate, policy);
|
||||
if (selected) return selected;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChannelIds: number[]) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
const selected = await tokenRouter.selectNextChannel(candidate, excludeChannelIds, policy);
|
||||
if (selected) return selected;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDownstreamPath(request: FastifyRequest): string {
|
||||
const rawUrl = request.raw.url || request.url || '';
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
return withoutQuery || '/v1beta/models';
|
||||
}
|
||||
|
||||
function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string {
|
||||
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||
return `/${normalizedVersion}/${normalizedAction}`;
|
||||
}
|
||||
|
||||
function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean {
|
||||
const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : [];
|
||||
const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : [];
|
||||
return supportedModels.length > 0 || allowedRouteIds.length > 0;
|
||||
}
|
||||
|
||||
function extractGeminiListedModelName(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return '';
|
||||
const rawName = typeof (item as { name?: unknown }).name === 'string'
|
||||
? (item as { name: string }).name.trim()
|
||||
: '';
|
||||
if (!rawName) return '';
|
||||
return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName;
|
||||
}
|
||||
|
||||
async function filterGeminiListedModelsForPolicy(
|
||||
payload: unknown,
|
||||
request: FastifyRequest,
|
||||
): Promise<unknown> {
|
||||
if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
if (!hasDownstreamModelRestrictions(policy)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const filteredModels: unknown[] = [];
|
||||
for (const item of (payload as { models: unknown[] }).models) {
|
||||
const modelName = extractGeminiListedModelName(item);
|
||||
if (!modelName) continue;
|
||||
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
|
||||
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
|
||||
if (decision && typeof decision.selectedChannelId !== 'number') continue;
|
||||
filteredModels.push(item);
|
||||
}
|
||||
|
||||
return {
|
||||
...(payload as Record<string, unknown>),
|
||||
models: filteredModels,
|
||||
};
|
||||
}
|
||||
|
||||
async function readRouteAwareGeminiModels(request: FastifyRequest): Promise<Array<{ name: string; displayName: string }>> {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
|
||||
.from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(
|
||||
eq(schema.modelAvailability.available, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
eq(schema.sites.status, 'active'),
|
||||
))
|
||||
.all();
|
||||
const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName })
|
||||
.from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true))
|
||||
.all();
|
||||
const deduped = Array.from(new Set([
|
||||
...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean),
|
||||
...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean),
|
||||
])).sort();
|
||||
|
||||
const allowed: Array<{ name: string; displayName: string }> = [];
|
||||
for (const modelName of deduped) {
|
||||
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
|
||||
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
|
||||
if (decision && typeof decision.selectedChannelId !== 'number') continue;
|
||||
allowed.push({
|
||||
name: `models/${modelName}`,
|
||||
displayName: modelName,
|
||||
});
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamPath: string,
|
||||
upstreamPath: string | null,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel || modelRequested,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost: 0,
|
||||
billingDetails: null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/gemini] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
|
||||
request.params as { geminiApiVersion?: string } | undefined,
|
||||
);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
let lastStatus = 503;
|
||||
let lastText = 'No available channels for Gemini models';
|
||||
let lastContentType = 'application/json';
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
const selected = retryCount === 0
|
||||
? await selectGeminiChannel(request)
|
||||
: await selectNextGeminiProbeChannel(request, excludeChannelIds);
|
||||
if (!selected) {
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
try {
|
||||
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
|
||||
let models = await readRouteAwareGeminiModels(request);
|
||||
if (models.length <= 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
models = await readRouteAwareGeminiModels(request);
|
||||
}
|
||||
return reply.code(200).send({ models });
|
||||
}
|
||||
|
||||
if (isGeminiCliPlatform(selected.site.platform)) {
|
||||
const filtered = await filterGeminiListedModelsForPolicy(
|
||||
{ models: GEMINI_CLI_STATIC_MODELS },
|
||||
request,
|
||||
);
|
||||
return reply.code(200).send(filtered);
|
||||
}
|
||||
|
||||
const upstream = await fetch(
|
||||
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
|
||||
{ method: 'GET' },
|
||||
);
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
lastStatus = upstream.status;
|
||||
lastText = text;
|
||||
lastContentType = upstream.headers.get('content-type') || 'application/json';
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const filtered = await filterGeminiListedModelsForPolicy(parsed, request);
|
||||
return reply.code(upstream.status).send(filtered);
|
||||
} catch {
|
||||
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||
}
|
||||
} catch (error) {
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
lastStatus = 502;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateContent = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options?: {
|
||||
downstreamProtocol?: 'gemini' | 'gemini-cli';
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
},
|
||||
) => {
|
||||
const downstreamProtocol = options?.downstreamProtocol || 'gemini';
|
||||
const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli';
|
||||
const cliRequestedModel = isGeminiCliDownstream
|
||||
? (typeof (request.body as Record<string, unknown> | null | undefined)?.model === 'string'
|
||||
? String((request.body as Record<string, unknown>).model).trim()
|
||||
: '')
|
||||
: '';
|
||||
const parsedPath = isGeminiCliDownstream
|
||||
? {
|
||||
apiVersion: 'v1beta',
|
||||
modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`,
|
||||
isStreamAction: options?.action === 'streamGenerateContent',
|
||||
requestedModel: cliRequestedModel,
|
||||
}
|
||||
: geminiGenerateContentTransformer.parseProxyRequestPath({
|
||||
rawUrl: request.raw.url || request.url || '',
|
||||
params: request.params as { geminiApiVersion?: string } | undefined,
|
||||
});
|
||||
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
||||
const isCountTokensAction = isGeminiCliDownstream
|
||||
? options?.action === 'countTokens'
|
||||
: modelActionPath.endsWith(':countTokens');
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamPath = resolveDownstreamPath(request);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
let lastStatus = 503;
|
||||
let lastText = 'No available channels for this model';
|
||||
let lastContentType = 'application/json';
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
const selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, policy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy);
|
||||
if (!selected) {
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const actualModel = selected.actualModel || requestedModel;
|
||||
const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
|
||||
actualModel,
|
||||
);
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
||||
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
|
||||
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
|
||||
const startTime = Date.now();
|
||||
let upstreamPath = '';
|
||||
|
||||
try {
|
||||
if (isDirectGeminiFamily) {
|
||||
if (isGeminiCli && !oauth?.projectId) {
|
||||
lastStatus = 500;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: 'Gemini CLI OAuth project is missing',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
const actualModelAction = modelActionPath.replace(
|
||||
/^models\/[^:]+/,
|
||||
`models/${actualModel}`,
|
||||
);
|
||||
upstreamPath = isInternalGemini
|
||||
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
|
||||
: resolveUpstreamPath(apiVersion, actualModelAction);
|
||||
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
||||
const requestBody = isInternalGemini
|
||||
? (
|
||||
isCountTokensAction
|
||||
? { request: normalizedBody }
|
||||
: wrapGeminiCliRequest({
|
||||
modelName: actualModel,
|
||||
projectId: oauth?.projectId || '',
|
||||
request: normalizedBody as Record<string, unknown>,
|
||||
})
|
||||
)
|
||||
: normalizedBody;
|
||||
const requestHeaders = isInternalGemini
|
||||
? {
|
||||
'Content-Type': 'application/json',
|
||||
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
...buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const targetUrl = isInternalGemini
|
||||
? `${selected.site.url}${upstreamPath}`
|
||||
: geminiGenerateContentTransformer.resolveActionUrl(
|
||||
selected.site.url,
|
||||
apiVersion,
|
||||
actualModelAction,
|
||||
selected.tokenValue,
|
||||
query,
|
||||
);
|
||||
const upstream = isInternalGemini
|
||||
? await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: {
|
||||
endpoint: 'chat',
|
||||
path: upstreamPath,
|
||||
headers: requestHeaders,
|
||||
body: requestBody as Record<string, unknown>,
|
||||
runtime: {
|
||||
executor: isGeminiCli ? 'gemini-cli' : 'antigravity',
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
oauthProjectId: oauth?.projectId || null,
|
||||
action: isCountTokensAction
|
||||
? 'countTokens'
|
||||
: (isStreamAction ? 'streamGenerateContent' : 'generateContent'),
|
||||
},
|
||||
},
|
||||
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
: await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
if (!upstream.ok) {
|
||||
lastStatus = upstream.status;
|
||||
lastContentType = contentType;
|
||||
lastText = await upstream.text();
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
lastStatus,
|
||||
Date.now() - startTime,
|
||||
lastText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return reply.code(lastStatus).send(JSON.parse(lastText));
|
||||
} catch {
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
}
|
||||
|
||||
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = upstream.status;
|
||||
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
if (!reader) {
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
const decoder = new TextDecoder();
|
||||
let rest = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
const chunkText = decoder.decode(value, { stream: true });
|
||||
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
|
||||
aggregateState,
|
||||
rest + chunkText,
|
||||
);
|
||||
rest = consumed.rest;
|
||||
for (const line of consumed.lines) {
|
||||
reply.raw.write(line);
|
||||
}
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
|
||||
aggregateState,
|
||||
rest + tail,
|
||||
);
|
||||
for (const line of consumed.lines) {
|
||||
reply.raw.write(line);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
reply.raw.end();
|
||||
}
|
||||
const parsedUsage = parseProxyUsage(aggregateState);
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await upstream.text();
|
||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
let parsedUsage = EMPTY_PROXY_USAGE;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream
|
||||
? unwrapGeminiCliPayload(parsed)
|
||||
: parsed;
|
||||
const responsePayload = isCountTokensAction
|
||||
? unwrappedPayload
|
||||
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||
aggregateState,
|
||||
unwrappedPayload,
|
||||
isStreamAction,
|
||||
);
|
||||
parsedUsage = parseProxyUsage(aggregateState);
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
);
|
||||
return reply.code(upstream.status).send(
|
||||
isGeminiCliDownstream && !isCountTokensAction
|
||||
? { response: responsePayload }
|
||||
: responsePayload,
|
||||
);
|
||||
} catch {
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
);
|
||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCountTokensAction) {
|
||||
lastStatus = 501;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: 'Gemini countTokens compatibility is not implemented for this upstream',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody as Record<string, unknown>,
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
});
|
||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
actualModel,
|
||||
'openai',
|
||||
requestedModel,
|
||||
);
|
||||
const buildEndpointRequest = (
|
||||
endpoint: 'chat' | 'messages' | 'responses',
|
||||
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
|
||||
) => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: openAiBody,
|
||||
downstreamFormat: 'openai',
|
||||
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
endpoint,
|
||||
path: endpointRequest.path,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = createChatEndpointStrategy({
|
||||
downstreamFormat: 'openai',
|
||||
endpointCandidates,
|
||||
modelName: actualModel,
|
||||
requestedModelHint: requestedModel,
|
||||
sitePlatform: selected.site.platform,
|
||||
isStream: isStreamAction,
|
||||
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
|
||||
endpoint,
|
||||
{ forceNormalizeClaudeBody },
|
||||
),
|
||||
dispatchRequest,
|
||||
});
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover: endpointStrategy.tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
});
|
||||
if (!endpointResult.ok) {
|
||||
lastStatus = endpointResult.status;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: endpointResult.errText,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
lastStatus,
|
||||
Date.now() - startTime,
|
||||
endpointResult.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
upstreamPath = endpointResult.upstreamPath;
|
||||
const upstream = endpointResult.upstream;
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {}
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText);
|
||||
const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({
|
||||
normalized: normalizedFinal,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
);
|
||||
return reply.code(upstream.status).send(
|
||||
isGeminiCliDownstream
|
||||
? { response: geminiResponse }
|
||||
: geminiResponse,
|
||||
);
|
||||
} catch (error) {
|
||||
lastStatus = 502;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath || null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply);
|
||||
const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
|
||||
downstreamProtocol: 'gemini-cli',
|
||||
action: 'generateContent',
|
||||
});
|
||||
const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
|
||||
downstreamProtocol: 'gemini-cli',
|
||||
action: 'streamGenerateContent',
|
||||
});
|
||||
const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
|
||||
downstreamProtocol: 'gemini-cli',
|
||||
action: 'countTokens',
|
||||
});
|
||||
|
||||
app.get('/v1beta/models', listModels);
|
||||
app.get('/gemini/:geminiApiVersion/models', listModels);
|
||||
app.post('/v1beta/models/*', generateContent);
|
||||
app.post('/gemini/:geminiApiVersion/models/*', generateContent);
|
||||
app.post('/v1internal::generateContent', geminiCliGenerateContent);
|
||||
app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent);
|
||||
app.post('/v1internal::countTokens', geminiCliCountTokens);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js';
|
||||
import {
|
||||
inferInputFileMimeType,
|
||||
normalizeInputFileBlock,
|
||||
toOpenAiChatFileBlock,
|
||||
toResponsesInputFileBlock,
|
||||
type NormalizedInputFile,
|
||||
} from '../../transformers/shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isSupportedFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export class ProxyInputFileResolutionError extends Error {
|
||||
statusCode: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(statusCode: number, payload: unknown) {
|
||||
super(typeof payload === 'object' && payload && 'error' in (payload as Record<string, unknown>)
|
||||
? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed')
|
||||
: 'input file resolution failed');
|
||||
this.name = 'ProxyInputFileResolutionError';
|
||||
this.statusCode = statusCode;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvalidRequest(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(400, {
|
||||
error: {
|
||||
message,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildNotFound(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(404, {
|
||||
error: {
|
||||
message,
|
||||
type: 'not_found_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInputFileBlock(
|
||||
item: Record<string, unknown>,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const normalized = normalizeInputFileBlock(item);
|
||||
if (!normalized) return item;
|
||||
|
||||
let resolved: NormalizedInputFile = { ...normalized };
|
||||
|
||||
if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) {
|
||||
const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner);
|
||||
if (!stored) {
|
||||
throw buildNotFound(`file not found: ${resolved.fileId}`);
|
||||
}
|
||||
resolved = {
|
||||
filename: stored.filename,
|
||||
mimeType: stored.mimeType,
|
||||
fileData: stored.contentBase64,
|
||||
};
|
||||
}
|
||||
|
||||
const inferredMimeType = inferInputFileMimeType(resolved);
|
||||
if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) {
|
||||
throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
if (inferredMimeType) {
|
||||
resolved.mimeType = inferredMimeType;
|
||||
}
|
||||
|
||||
const type = typeof item.type === 'string' ? item.type : '';
|
||||
if (type === 'input_file') {
|
||||
return toResponsesInputFileBlock(resolved);
|
||||
}
|
||||
if (type === 'file') {
|
||||
return toOpenAiChatFileBlock(resolved);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function inlineLocalInputFileReferences(
|
||||
value: unknown,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner)));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type : '';
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
return resolveInputFileBlock(value, owner);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listModelsSurface } from './modelsSurface.js';
|
||||
|
||||
describe('listModelsSurface', () => {
|
||||
it('returns OpenAI list shape and hides models without a resolvable channel', async () => {
|
||||
const result = await listModelsSurface({
|
||||
downstreamPolicy: { type: 'all' },
|
||||
responseFormat: 'openai',
|
||||
tokenRouter: {
|
||||
getAvailableModels: vi.fn().mockResolvedValue(['routable-model', 'orphan-model']),
|
||||
explainSelection: vi.fn()
|
||||
.mockResolvedValueOnce({ selectedChannelId: null })
|
||||
.mockResolvedValueOnce({ selectedChannelId: 11 }),
|
||||
},
|
||||
refreshModelsAndRebuildRoutes: vi.fn(),
|
||||
isModelAllowed: vi.fn().mockResolvedValue(true),
|
||||
now: () => new Date('2026-03-19T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'routable-model',
|
||||
object: 'model',
|
||||
created: 1773878400,
|
||||
owned_by: 'metapi',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns Claude list shape when requested', async () => {
|
||||
const result = await listModelsSurface({
|
||||
downstreamPolicy: { type: 'all' },
|
||||
responseFormat: 'claude',
|
||||
tokenRouter: {
|
||||
getAvailableModels: vi.fn().mockResolvedValue(['claude-opus-4-6']),
|
||||
explainSelection: vi.fn().mockResolvedValue({ selectedChannelId: 22 }),
|
||||
},
|
||||
refreshModelsAndRebuildRoutes: vi.fn(),
|
||||
isModelAllowed: vi.fn().mockResolvedValue(true),
|
||||
now: () => new Date('2026-03-19T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{
|
||||
id: 'claude-opus-4-6',
|
||||
type: 'model',
|
||||
display_name: 'claude-opus-4-6',
|
||||
created_at: '2026-03-19T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
first_id: 'claude-opus-4-6',
|
||||
last_id: 'claude-opus-4-6',
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies downstream policy filtering before selection checks and refreshes once when the first read is empty', async () => {
|
||||
const getAvailableModels = vi.fn()
|
||||
.mockResolvedValueOnce(['blocked-model'])
|
||||
.mockResolvedValueOnce(['allowed-model']);
|
||||
const refreshModelsAndRebuildRoutes = vi.fn().mockResolvedValue(undefined);
|
||||
const isModelAllowed = vi.fn()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
const explainSelection = vi.fn().mockResolvedValue({ selectedChannelId: 33 });
|
||||
|
||||
const result = await listModelsSurface({
|
||||
downstreamPolicy: { type: 'whitelist' },
|
||||
responseFormat: 'openai',
|
||||
tokenRouter: {
|
||||
getAvailableModels,
|
||||
explainSelection,
|
||||
},
|
||||
refreshModelsAndRebuildRoutes,
|
||||
isModelAllowed,
|
||||
now: () => new Date('2026-03-19T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(refreshModelsAndRebuildRoutes).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'allowed-model',
|
||||
object: 'model',
|
||||
created: 1773878400,
|
||||
owned_by: 'metapi',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
function isSearchPseudoModel(modelName: string): boolean {
|
||||
const normalized = (modelName || '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === '__search' || /^__.+_search$/.test(normalized);
|
||||
}
|
||||
|
||||
type ModelsSurfaceInput = {
|
||||
downstreamPolicy: unknown;
|
||||
responseFormat: 'openai' | 'claude';
|
||||
tokenRouter: {
|
||||
getAvailableModels(): Promise<string[]>;
|
||||
explainSelection(modelName: string, excludeChannelIds: number[], downstreamPolicy: unknown): Promise<{
|
||||
selectedChannelId?: number | null;
|
||||
}>;
|
||||
};
|
||||
refreshModelsAndRebuildRoutes(): Promise<unknown>;
|
||||
isModelAllowed(modelName: string, downstreamPolicy: unknown): Promise<boolean>;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
async function readVisibleModels(input: ModelsSurfaceInput): Promise<string[]> {
|
||||
const deduped = Array.from(new Set(await input.tokenRouter.getAvailableModels()))
|
||||
.filter((modelName) => !isSearchPseudoModel(modelName))
|
||||
.sort();
|
||||
const allowed: string[] = [];
|
||||
for (const modelName of deduped) {
|
||||
if (!await input.isModelAllowed(modelName, input.downstreamPolicy)) {
|
||||
continue;
|
||||
}
|
||||
const decision = await input.tokenRouter.explainSelection(modelName, [], input.downstreamPolicy);
|
||||
if (typeof decision.selectedChannelId === 'number') {
|
||||
allowed.push(modelName);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
export async function listModelsSurface(input: ModelsSurfaceInput) {
|
||||
let models = await readVisibleModels(input);
|
||||
if (models.length === 0) {
|
||||
await input.refreshModelsAndRebuildRoutes();
|
||||
models = await readVisibleModels(input);
|
||||
}
|
||||
|
||||
const now = input.now?.() ?? new Date();
|
||||
if (input.responseFormat === 'claude') {
|
||||
const data = models.map((id) => ({
|
||||
id,
|
||||
type: 'model' as const,
|
||||
display_name: id,
|
||||
created_at: now.toISOString(),
|
||||
}));
|
||||
return {
|
||||
data,
|
||||
first_id: data[0]?.id || null,
|
||||
last_id: data[data.length - 1]?.id || null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list' as const,
|
||||
data: models.map((id) => ({
|
||||
id,
|
||||
object: 'model' as const,
|
||||
created: Math.floor(now.getTime() / 1000),
|
||||
owned_by: 'metapi',
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
import { TextDecoder } from 'node:util';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import {
|
||||
buildUpstreamEndpointRequest,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
hasNonImageFileInputInOpenAiBody,
|
||||
resolveResponsesBodyInputFiles,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
import {
|
||||
buildOauthProviderHeaders,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { collectResponsesFinalPayloadFromSse } from '../../routes/proxy/responsesSseFinal.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
} from '../../routes/proxy/geminiCliCompat.js';
|
||||
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
|
||||
import { detectCliProfile } from '../cliProfiles/registry.js';
|
||||
import type { CliProfileId } from '../cliProfiles/types.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function normalizeIncludeList(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function hasExplicitInclude(body: Record<string, unknown>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(body, 'include');
|
||||
}
|
||||
|
||||
function hasResponsesReasoningRequest(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
||||
return relevantKeys.some((key) => {
|
||||
const entry = value[key];
|
||||
if (typeof entry === 'string') return entry.trim().length > 0;
|
||||
return entry !== undefined && entry !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function carriesResponsesReasoningContinuity(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => carriesResponsesReasoningContinuity(item));
|
||||
}
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : '';
|
||||
if (type === 'reasoning') {
|
||||
if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value.summary) && value.summary.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return carriesResponsesReasoningContinuity(value.input)
|
||||
|| carriesResponsesReasoningContinuity(value.content);
|
||||
}
|
||||
|
||||
function wantsNativeResponsesReasoning(body: unknown): boolean {
|
||||
if (!isRecord(body)) return false;
|
||||
const include = normalizeIncludeList(body.include);
|
||||
if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) {
|
||||
return true;
|
||||
}
|
||||
if (carriesResponsesReasoningContinuity(body.input)) {
|
||||
return true;
|
||||
}
|
||||
if (hasExplicitInclude(body)) {
|
||||
return false;
|
||||
}
|
||||
return hasResponsesReasoningRequest(body.reasoning);
|
||||
}
|
||||
|
||||
function carriesResponsesFileUrlInput(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => carriesResponsesFileUrlInput(item));
|
||||
}
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const normalizedFile = normalizeInputFileBlock(value);
|
||||
if (normalizedFile?.fileUrl) return true;
|
||||
|
||||
return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry));
|
||||
}
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
|
||||
type DownstreamClientContext = {
|
||||
clientKind: CliProfileId;
|
||||
sessionId?: string;
|
||||
traceHint?: string;
|
||||
};
|
||||
|
||||
function detectDownstreamClientContext(input: {
|
||||
downstreamPath: string;
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}): DownstreamClientContext {
|
||||
const detected = detectCliProfile(input);
|
||||
return {
|
||||
clientKind: detected.id,
|
||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
||||
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
||||
? body.prompt_cache_key.trim()
|
||||
: '';
|
||||
return promptCacheKey || null;
|
||||
}
|
||||
|
||||
export async function handleOpenAiResponsesSurfaceRequest(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
downstreamPath: '/v1/responses' | '/v1/responses/compact',
|
||||
) {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body,
|
||||
});
|
||||
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||
request.headers as Record<string, unknown>,
|
||||
);
|
||||
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
|
||||
defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (parsedRequestEnvelope.error) {
|
||||
return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload);
|
||||
}
|
||||
const requestEnvelope = parsedRequestEnvelope.value!;
|
||||
const requestedModel = requestEnvelope.model;
|
||||
const isStream = requestEnvelope.stream;
|
||||
const isCompactRequest = downstreamPath === '/v1/responses/compact';
|
||||
if (isCompactRequest && isStream) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'stream is not supported on /v1/responses/compact',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let normalizedResponsesBody: Record<string, unknown> = {
|
||||
...requestEnvelope.parsed.normalizedBody,
|
||||
model: modelName,
|
||||
stream: isStream,
|
||||
};
|
||||
if (body.generate === false) {
|
||||
normalizedResponsesBody.generate = false;
|
||||
}
|
||||
if (owner) {
|
||||
try {
|
||||
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyInputFileResolutionError) {
|
||||
return reply.code(error.statusCode).send(error.payload);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(
|
||||
normalizedResponsesBody,
|
||||
modelName,
|
||||
isStream,
|
||||
{ defaultEncryptedReasoningInclude },
|
||||
);
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
||||
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
|
||||
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'Responses input_file.file_url requires an upstream /v1/responses endpoint; current site only supports /v1/messages.',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
modelName,
|
||||
'responses',
|
||||
requestedModel,
|
||||
{
|
||||
hasNonImageFileInput,
|
||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||
},
|
||||
);
|
||||
if (requiresNativeResponsesFileUrl) {
|
||||
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
|
||||
}
|
||||
if (endpointCandidates.length === 0) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
}
|
||||
const buildProviderHeaders = () => (
|
||||
buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody);
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: upstreamStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: openAiBody,
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: normalizedResponsesBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildProviderHeaders(),
|
||||
codexExplicitSessionId,
|
||||
});
|
||||
const upstreamPath = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
? `${endpointRequest.path}/compact`
|
||||
: endpointRequest.path
|
||||
);
|
||||
return {
|
||||
endpoint,
|
||||
path: upstreamPath,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream: isStream || isCodexSite,
|
||||
requiresNativeResponsesFileUrl,
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
upstreamPath: refreshedRequest.path,
|
||||
};
|
||||
}
|
||||
ctx.request = refreshedRequest;
|
||||
ctx.response = refreshedResponse;
|
||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||
} catch {
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
}
|
||||
}
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
ctx.response.status,
|
||||
Date.now() - startTime,
|
||||
ctx.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpointResult.ok) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
const rawErrText = endpointResult.rawErrText || errText;
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
status,
|
||||
Date.now() - startTime,
|
||||
errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: selected.account.id,
|
||||
statusCode: status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${status}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${status}`,
|
||||
});
|
||||
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||
const finalizeStreamSuccess = async (parsedUsage: UsageSummary, latency: number) => {
|
||||
let usageForLog = {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
};
|
||||
let estimatedCost = 0;
|
||||
let billingDetails: unknown = null;
|
||||
|
||||
try {
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
usageForLog = {
|
||||
promptTokens: resolvedUsage.promptTokens,
|
||||
completionTokens: resolvedUsage.completionTokens,
|
||||
totalTokens: resolvedUsage.totalTokens,
|
||||
};
|
||||
const billing = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
estimatedCost = billing.estimatedCost;
|
||||
billingDetails = billing.billingDetails;
|
||||
} catch (error) {
|
||||
console.error('[responses] post-stream bookkeeping failed:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[responses] post-stream success logging failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isStream) {
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
const startSseResponse = () => {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
};
|
||||
|
||||
let parsedUsage: UsageSummary = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
const writeLines = (lines: string[]) => {
|
||||
for (const line of lines) reply.raw.write(line);
|
||||
};
|
||||
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
||||
modelName,
|
||||
successfulUpstreamPath,
|
||||
strictTerminalEvents: Object.entries(request.headers as Record<string, unknown>)
|
||||
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
||||
&& String(rawValue).trim() === '1'),
|
||||
getUsage: () => parsedUsage,
|
||||
onParsedPayload: (payload) => {
|
||||
if (payload && typeof payload === 'object') {
|
||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
||||
}
|
||||
},
|
||||
writeLines,
|
||||
writeRaw: (chunk) => {
|
||||
reply.raw.write(chunk);
|
||||
},
|
||||
});
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
|
||||
parsedUsage = parseProxyUsage(upstreamData);
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
return;
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
let rawText = '';
|
||||
const decoder = new TextDecoder();
|
||||
const reader = baseReader
|
||||
? {
|
||||
async read() {
|
||||
const result = await baseReader.read();
|
||||
if (result.value) {
|
||||
rawText += decoder.decode(result.value, { stream: true });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async cancel(reason?: unknown) {
|
||||
return baseReader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
return baseReader.releaseLock();
|
||||
},
|
||||
}
|
||||
: baseReader;
|
||||
const streamResult = await streamSession.run(reader, reply.raw);
|
||||
rawText += decoder.decode();
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once SSE has been hijacked and bytes may already be on the wire, we
|
||||
// must not attempt to convert stream failures into a fresh HTTP error
|
||||
// response or retry on another channel. Responses stream failures are
|
||||
// handled in-band by the proxy stream session.
|
||||
|
||||
await finalizeStreamSuccess(parsedUsage, latency);
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
let rawText = '';
|
||||
let upstreamData: unknown;
|
||||
if (
|
||||
upstreamContentType.includes('text/event-stream')
|
||||
&& (
|
||||
successfulUpstreamPath.endsWith('/responses')
|
||||
|| successfulUpstreamPath.endsWith('/responses/compact')
|
||||
)
|
||||
) {
|
||||
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
|
||||
rawText = collected.rawText;
|
||||
upstreamData = collected.payload;
|
||||
} else {
|
||||
rawText = await upstream.text();
|
||||
upstreamData = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
}
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
modelName,
|
||||
rawText,
|
||||
);
|
||||
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
|
||||
upstreamPayload: upstreamData,
|
||||
normalized,
|
||||
usage: parsedUsage,
|
||||
serializationMode: isCompactRequest ? 'compact' : 'response',
|
||||
});
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
err.message,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: err.message || 'network failure',
|
||||
});
|
||||
return reply.code(502).send({
|
||||
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamPath: string,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
upstreamPath: string | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/responses] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getLocalDayRangeUtc,
|
||||
getLocalRangeStartUtc,
|
||||
parseStoredUtcDateTime,
|
||||
type StoredUtcDateTimeInput,
|
||||
toLocalDayKeyFromStoredUtc,
|
||||
} from '../../services/localTimeService.js';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
@@ -202,7 +203,7 @@ type SiteAvailabilitySiteRow = {
|
||||
|
||||
type SiteAvailabilityLogRow = {
|
||||
siteId: number | null;
|
||||
createdAt: string | null;
|
||||
createdAt: StoredUtcDateTimeInput;
|
||||
status: string | null;
|
||||
latencyMs: number | null;
|
||||
};
|
||||
|
||||
@@ -244,6 +244,75 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('fills missing sourceModel from source exact routes when loading explicit-group channels', async () => {
|
||||
const sourceA = await seedAccountWithToken('deepseek-chat');
|
||||
const sourceB = await seedAccountWithToken('deepseek-reasoner');
|
||||
|
||||
const exactRouteA = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'deepseek-chat',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const exactRouteB = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'deepseek-reasoner',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values([
|
||||
{
|
||||
routeId: exactRouteA.id,
|
||||
accountId: sourceA.account.id,
|
||||
tokenId: sourceA.token.id,
|
||||
sourceModel: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
},
|
||||
{
|
||||
routeId: exactRouteB.id,
|
||||
accountId: sourceB.account.id,
|
||||
tokenId: sourceB.token.id,
|
||||
sourceModel: null,
|
||||
priority: 1,
|
||||
weight: 8,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes',
|
||||
payload: {
|
||||
routeMode: 'explicit_group',
|
||||
displayName: 'deepseekv1',
|
||||
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const createdRouteId = (createResponse.json() as { id: number }).id;
|
||||
|
||||
const channelsResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/routes/${createdRouteId}/channels`,
|
||||
});
|
||||
|
||||
expect(channelsResponse.statusCode).toBe(200);
|
||||
expect(channelsResponse.json()).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
routeId: exactRouteA.id,
|
||||
accountId: sourceA.account.id,
|
||||
sourceModel: 'deepseek-chat',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
routeId: exactRouteB.id,
|
||||
accountId: sourceB.account.id,
|
||||
sourceModel: 'deepseek-reasoner',
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it('rejects invalid explicit-group payloads', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -534,7 +534,7 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
const explicitSourceRouteIds = Array.from(new Set(routes
|
||||
.filter((route) => isExplicitGroupRoute(route))
|
||||
.flatMap((route) => route.sourceRouteIds)));
|
||||
const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0
|
||||
const explicitSourceRoutes = explicitSourceRouteIds.length > 0
|
||||
? (await db.select({
|
||||
id: schema.tokenRoutes.id,
|
||||
modelPattern: schema.tokenRoutes.modelPattern,
|
||||
@@ -543,9 +543,10 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
}).from(schema.tokenRoutes)
|
||||
.where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds))
|
||||
.all())
|
||||
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
|
||||
.map((route) => route.id)
|
||||
: [];
|
||||
const enabledExplicitSourceRouteIds = explicitSourceRoutes
|
||||
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
|
||||
.map((route) => route.id);
|
||||
const actualRouteIds = Array.from(new Set([
|
||||
...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id),
|
||||
...enabledExplicitSourceRouteIds,
|
||||
@@ -554,6 +555,14 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
return new Map(routes.map((route) => [route.id, []]));
|
||||
}
|
||||
|
||||
const actualRouteById = new Map<number, { modelPattern: string; routeMode: string | null }>();
|
||||
for (const route of routes.filter((item) => !isExplicitGroupRoute(item))) {
|
||||
actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null });
|
||||
}
|
||||
for (const route of explicitSourceRoutes) {
|
||||
actualRouteById.set(route.id, { modelPattern: route.modelPattern, routeMode: route.routeMode ?? null });
|
||||
}
|
||||
|
||||
const channelRows = await db.select().from(schema.routeChannels)
|
||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
@@ -565,13 +574,19 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number
|
||||
|
||||
for (const row of channelRows) {
|
||||
const routeId = row.route_channels.routeId;
|
||||
const actualRoute = actualRouteById.get(routeId);
|
||||
const fallbackSourceModel = actualRoute && !isExplicitGroupRoute(actualRoute) && isExactModelPattern(actualRoute.modelPattern)
|
||||
? actualRoute.modelPattern
|
||||
: null;
|
||||
const resolvedSourceModel = (row.route_channels.sourceModel || fallbackSourceModel || '').trim();
|
||||
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
|
||||
channelsByActualRouteId.get(routeId)!.push({
|
||||
...row.route_channels,
|
||||
sourceModel: resolvedSourceModel || null,
|
||||
account: row.accounts,
|
||||
site: row.sites,
|
||||
token: row.account_tokens
|
||||
? {
|
||||
token: row.account_tokens
|
||||
? {
|
||||
id: row.account_tokens.id,
|
||||
name: row.account_tokens.name,
|
||||
accountId: row.account_tokens.accountId,
|
||||
|
||||
@@ -8,12 +8,15 @@ function readSource(relativePath: string): string {
|
||||
describe('proxy route architecture boundaries', () => {
|
||||
it('keeps shared protocol helpers out of chat route', () => {
|
||||
const source = readSource('./chat.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts');
|
||||
expect(source).toContain("from '../../proxy-core/surfaces/chatSurface.js'");
|
||||
expect(source).not.toContain("from './chatFormats.js'");
|
||||
expect(source).toContain("from '../../transformers/openai/chat/index.js'");
|
||||
expect(source).toContain("from '../../transformers/anthropic/messages/index.js'");
|
||||
expect(surfaceSource).toContain("from '../../transformers/openai/chat/index.js'");
|
||||
expect(surfaceSource).toContain("from '../../transformers/anthropic/messages/index.js'");
|
||||
});
|
||||
it('keeps anthropic-specific stream orchestration out of chat route', () => {
|
||||
const source = readSource('./chat.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts');
|
||||
expect(source).not.toContain('serializeAnthropicRawSseEvent');
|
||||
expect(source).not.toContain('syncAnthropicRawStreamStateFromEvent');
|
||||
expect(source).not.toContain('isAnthropicRawSseEventName');
|
||||
@@ -28,14 +31,15 @@ describe('proxy route architecture boundaries', () => {
|
||||
expect(source).not.toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
|
||||
expect(source).not.toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream(');
|
||||
expect(source).not.toContain('openAiChatTransformer.serializeUpstreamFinalAsStream(');
|
||||
expect(source).toContain('openAiChatTransformer.proxyStream.createSession(');
|
||||
expect(source).toContain('streamSession.consumeUpstreamFinalPayload(');
|
||||
expect(source).toContain('streamSession.run(');
|
||||
expect(surfaceSource).toContain('openAiChatTransformer.proxyStream.createSession(');
|
||||
expect(surfaceSource).toContain('streamSession.consumeUpstreamFinalPayload(');
|
||||
expect(surfaceSource).toContain('streamSession.run(');
|
||||
});
|
||||
|
||||
it('keeps chat endpoint retry and downgrade strategy out of the route', () => {
|
||||
const source = readSource('./chat.ts');
|
||||
expect(source).toContain('downstreamTransformer.compatibility.createEndpointStrategy(');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts');
|
||||
expect(surfaceSource).toContain('downstreamTransformer.compatibility.createEndpointStrategy(');
|
||||
expect(source).not.toContain('anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody(');
|
||||
expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility(');
|
||||
expect(source).not.toContain('promoteResponsesCandidateAfterLegacyChatError(');
|
||||
@@ -46,6 +50,8 @@ describe('proxy route architecture boundaries', () => {
|
||||
|
||||
it('keeps responses protocol assembly out of responses route', () => {
|
||||
const source = readSource('./responses.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts');
|
||||
expect(source).toContain("from '../../proxy-core/surfaces/openAiResponsesSurface.js'");
|
||||
expect(source).not.toContain('function toResponsesPayload(');
|
||||
expect(source).not.toContain('function createResponsesStreamState(');
|
||||
expect(source).not.toContain("from '../../transformers/openai/responses/conversion.js'");
|
||||
@@ -57,19 +63,20 @@ describe('proxy route architecture boundaries', () => {
|
||||
expect(source).not.toContain("from './protocolCompat.js'");
|
||||
expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses(');
|
||||
expect(source).not.toContain('function normalizeText(');
|
||||
expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody(');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.createState(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.serialize(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||
expect(source).toContain('streamSession.run(');
|
||||
expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal(');
|
||||
expect(surfaceSource).toContain('openAiResponsesTransformer.inbound.toOpenAiBody(');
|
||||
expect(surfaceSource).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
|
||||
expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.createState(');
|
||||
expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.serialize(');
|
||||
expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||
expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||
expect(surfaceSource).toContain('streamSession.run(');
|
||||
expect(surfaceSource).toContain('openAiResponsesTransformer.outbound.serializeFinal(');
|
||||
});
|
||||
|
||||
it('keeps responses endpoint retry and downgrade strategy out of the route', () => {
|
||||
const source = readSource('./responses.ts');
|
||||
expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts');
|
||||
expect(surfaceSource).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldRetry(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryBodies(');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders(');
|
||||
@@ -87,35 +94,65 @@ describe('proxy route architecture boundaries', () => {
|
||||
|
||||
it('keeps gemini runtime closure in transformer-owned helpers', () => {
|
||||
const source = readSource('./gemini.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/geminiSurface.ts');
|
||||
expect(source).toContain("from '../../proxy-core/surfaces/geminiSurface.js'");
|
||||
expect(source).not.toContain('outbound.serializeAggregateResponse(');
|
||||
expect(source).not.toContain('aggregator.apply(');
|
||||
expect(source).not.toContain('stream.serializeAggregateSsePayload(');
|
||||
expect(source).not.toContain('stream.serializeAggregateJsonPayload(');
|
||||
expect(source).not.toContain('stream.applyJsonPayloadToAggregate(');
|
||||
expect(source).not.toContain('stream.parseSsePayloads(');
|
||||
expect(source).toContain('stream.consumeUpstreamSseBuffer(');
|
||||
expect(source).toContain('stream.serializeUpstreamJsonPayload(');
|
||||
expect(surfaceSource).toContain('stream.consumeUpstreamSseBuffer(');
|
||||
expect(surfaceSource).toContain('stream.serializeUpstreamJsonPayload(');
|
||||
});
|
||||
|
||||
it('keeps proxy file persistence out of files route', () => {
|
||||
const source = readSource('./files.ts');
|
||||
expect(source).toContain("from '../../proxy-core/surfaces/filesSurface.js'");
|
||||
expect(source).not.toContain('saveProxyFile(');
|
||||
expect(source).not.toContain('listProxyFilesByOwner(');
|
||||
expect(source).not.toContain('getProxyFileByPublicIdForOwner(');
|
||||
expect(source).not.toContain('getProxyFileContentByPublicIdForOwner(');
|
||||
expect(source).not.toContain('softDeleteProxyFileByPublicIdForOwner(');
|
||||
});
|
||||
|
||||
it('keeps chat stream lifecycle behind transformer-owned facade', () => {
|
||||
const source = readSource('./chat.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/chatSurface.ts');
|
||||
expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'");
|
||||
expect(source).not.toContain('createProxyStreamLifecycle');
|
||||
expect(source).not.toContain('let shouldTerminateEarly = false;');
|
||||
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
|
||||
expect(source).not.toContain('writeDone();');
|
||||
expect(source).toContain('openAiChatTransformer.proxyStream.createSession(');
|
||||
expect(surfaceSource).toContain('openAiChatTransformer.proxyStream.createSession(');
|
||||
});
|
||||
|
||||
it('keeps responses stream lifecycle behind transformer-owned facade', () => {
|
||||
const source = readSource('./responses.ts');
|
||||
const surfaceSource = readSource('../../proxy-core/surfaces/openAiResponsesSurface.ts');
|
||||
expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'");
|
||||
expect(source).not.toContain('createProxyStreamLifecycle');
|
||||
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
|
||||
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||
expect(source).not.toContain('reply.raw.end();');
|
||||
expect(source).toContain('reply.hijack();');
|
||||
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||
expect(surfaceSource).not.toContain('openAiResponsesTransformer.aggregator.complete(');
|
||||
expect(surfaceSource).toContain('reply.hijack();');
|
||||
expect(surfaceSource).toContain('openAiResponsesTransformer.proxyStream.createSession(');
|
||||
});
|
||||
|
||||
it('keeps canonical transformer contracts imported from the transformer boundary only', () => {
|
||||
const chatSource = readSource('./chat.ts');
|
||||
const responsesSource = readSource('./responses.ts');
|
||||
const geminiSource = readSource('./gemini.ts');
|
||||
|
||||
expect(chatSource).not.toContain("from '../proxy-core/");
|
||||
expect(responsesSource).not.toContain("from '../proxy-core/");
|
||||
expect(geminiSource).not.toContain("from '../proxy-core/");
|
||||
expect(chatSource).not.toContain("from '../../transformers/contracts.js'");
|
||||
expect(responsesSource).not.toContain("from '../../transformers/contracts.js'");
|
||||
expect(geminiSource).not.toContain("from '../../transformers/contracts.js'");
|
||||
expect(chatSource).not.toContain("from '../../transformers/canonical/");
|
||||
expect(responsesSource).not.toContain("from '../../transformers/canonical/");
|
||||
expect(geminiSource).not.toContain("from '../../transformers/canonical/");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+7
-1003
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ describe('extractClaudeCodeSessionId', () => {
|
||||
});
|
||||
|
||||
describe('isCodexResponsesSurface', () => {
|
||||
it('detects Codex responses surface from originator and stainless headers', () => {
|
||||
it('detects Codex responses surface from originator, stainless, and turn-state headers', () => {
|
||||
expect(isCodexResponsesSurface({
|
||||
originator: 'codex_cli_rs',
|
||||
})).toBe(true);
|
||||
@@ -27,6 +27,10 @@ describe('isCodexResponsesSurface', () => {
|
||||
expect(isCodexResponsesSurface({
|
||||
'x-stainless-lang': 'typescript',
|
||||
})).toBe(true);
|
||||
|
||||
expect(isCodexResponsesSurface({
|
||||
'x-codex-turn-state': 'turn-state-123',
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for generic responses clients', () => {
|
||||
@@ -109,4 +113,21 @@ describe('detectDownstreamClientContext', () => {
|
||||
clientKind: 'generic',
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes Gemini CLI internal routes as a first-class downstream client kind', () => {
|
||||
expect(detectDownstreamClientContext({
|
||||
downstreamPath: '/v1internal:generateContent',
|
||||
body: {
|
||||
model: 'gpt-4.1',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})).toEqual({
|
||||
clientKind: 'gemini_cli',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export type DownstreamClientKind = 'generic' | 'codex' | 'claude_code';
|
||||
import { extractClaudeCodeSessionId as extractClaudeCodeSessionIdViaProfile } from '../../proxy-core/cliProfiles/claudeCodeProfile.js';
|
||||
import {
|
||||
detectCliProfile,
|
||||
} from '../../proxy-core/cliProfiles/registry.js';
|
||||
import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '../../proxy-core/cliProfiles/codexProfile.js';
|
||||
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
|
||||
|
||||
export type DownstreamClientKind = CliProfileId;
|
||||
|
||||
export type DownstreamClientContext = {
|
||||
clientKind: DownstreamClientKind;
|
||||
@@ -6,76 +13,12 @@ export type DownstreamClientContext = {
|
||||
traceHint?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function headerValueToString(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const trimmed = item.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
|
||||
if (!headers) return null;
|
||||
const normalizedTarget = targetKey.trim().toLowerCase();
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
|
||||
return headerValueToString(rawValue);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||
if (!headers) return false;
|
||||
|
||||
let sawOpenAiBeta = false;
|
||||
let sawStainless = false;
|
||||
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
const value = headerValueToString(rawValue);
|
||||
if (!key || !value) continue;
|
||||
|
||||
if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') {
|
||||
return true;
|
||||
}
|
||||
if (key === 'openai-beta') {
|
||||
sawOpenAiBeta = true;
|
||||
}
|
||||
if (key.startsWith('x-stainless-')) {
|
||||
sawStainless = true;
|
||||
}
|
||||
}
|
||||
|
||||
return sawOpenAiBeta || sawStainless;
|
||||
return isCodexResponsesSurfaceViaProfile(headers);
|
||||
}
|
||||
|
||||
const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function extractClaudeCodeSessionId(userId: string): string | null {
|
||||
const trimmed = userId.trim();
|
||||
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
|
||||
|
||||
const sessionPrefix = '__session_';
|
||||
const sessionIndex = trimmed.lastIndexOf(sessionPrefix);
|
||||
if (sessionIndex === -1) return null;
|
||||
|
||||
const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim();
|
||||
return sessionId || null;
|
||||
return extractClaudeCodeSessionIdViaProfile(userId);
|
||||
}
|
||||
|
||||
export function detectDownstreamClientContext(input: {
|
||||
@@ -83,38 +26,10 @@ export function detectDownstreamClientContext(input: {
|
||||
headers?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}): DownstreamClientContext {
|
||||
const normalizedPath = input.downstreamPath.trim().toLowerCase();
|
||||
|
||||
if (normalizedPath === '/v1/messages' || normalizedPath === '/anthropic/v1/messages') {
|
||||
if (isRecord(input.body) && isRecord(input.body.metadata)) {
|
||||
const userId = typeof input.body.metadata.user_id === 'string'
|
||||
? input.body.metadata.user_id.trim()
|
||||
: '';
|
||||
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
|
||||
if (sessionId) {
|
||||
return {
|
||||
clientKind: 'claude_code',
|
||||
sessionId,
|
||||
traceHint: sessionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { clientKind: 'generic' };
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/v1/responses') && isCodexResponsesSurface(input.headers)) {
|
||||
const sessionId = getHeaderValue(input.headers, 'session_id') || getHeaderValue(input.headers, 'session-id');
|
||||
if (sessionId) {
|
||||
return {
|
||||
clientKind: 'codex',
|
||||
sessionId,
|
||||
traceHint: sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
return { clientKind: 'codex' };
|
||||
}
|
||||
|
||||
return { clientKind: 'generic' };
|
||||
const detected = detectCliProfile(input);
|
||||
return {
|
||||
clientKind: detected.id,
|
||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,159 +1 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import {
|
||||
getProxyFileByPublicIdForOwner,
|
||||
getProxyFileContentByPublicIdForOwner,
|
||||
listProxyFilesByOwner,
|
||||
saveProxyFile,
|
||||
softDeleteProxyFileByPublicIdForOwner,
|
||||
} from '../../services/proxyFileStore.js';
|
||||
import { ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||
|
||||
function invalidRequest(reply: FastifyReply, message: string) {
|
||||
return reply.code(400).send({ error: { message, type: 'invalid_request_error' } });
|
||||
}
|
||||
|
||||
function notFound(reply: FastifyReply, message = 'file not found') {
|
||||
return reply.code(404).send({ error: { message, type: 'not_found_error' } });
|
||||
}
|
||||
|
||||
function toUnixSeconds(sqlDateTime: string | null | undefined): number {
|
||||
if (!sqlDateTime) return Math.floor(Date.now() / 1000);
|
||||
const parsed = Date.parse(sqlDateTime.replace(' ', 'T') + 'Z');
|
||||
if (!Number.isFinite(parsed)) return Math.floor(Date.now() / 1000);
|
||||
return Math.floor(parsed / 1000);
|
||||
}
|
||||
|
||||
function toFileObject(record: {
|
||||
publicId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
purpose: string | null;
|
||||
byteSize: number;
|
||||
createdAt: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: record.publicId,
|
||||
object: 'file',
|
||||
bytes: record.byteSize,
|
||||
created_at: toUnixSeconds(record.createdAt),
|
||||
filename: record.filename,
|
||||
purpose: record.purpose || 'assistants',
|
||||
mime_type: record.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
function inferMimeTypeFromFilename(filename: string): string {
|
||||
const normalized = filename.trim().toLowerCase();
|
||||
if (normalized.endsWith('.pdf')) return 'application/pdf';
|
||||
if (normalized.endsWith('.txt')) return 'text/plain';
|
||||
if (normalized.endsWith('.md') || normalized.endsWith('.markdown')) return 'text/markdown';
|
||||
if (normalized.endsWith('.json')) return 'application/json';
|
||||
if (normalized.endsWith('.png')) return 'image/png';
|
||||
if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (normalized.endsWith('.gif')) return 'image/gif';
|
||||
if (normalized.endsWith('.webp')) return 'image/webp';
|
||||
if (normalized.endsWith('.wav')) return 'audio/wav';
|
||||
if (normalized.endsWith('.mp3')) return 'audio/mpeg';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedProxyFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export async function filesProxyRoute(app: FastifyInstance) {
|
||||
ensureMultipartBufferParser(app);
|
||||
|
||||
app.post('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
|
||||
const formData = await parseMultipartFormData(request);
|
||||
if (!formData) {
|
||||
return invalidRequest(reply, 'multipart/form-data with a file field is required');
|
||||
}
|
||||
|
||||
const fileEntry = formData.get('file');
|
||||
if (!fileEntry || typeof fileEntry !== 'object' || typeof (fileEntry as File).arrayBuffer !== 'function') {
|
||||
return invalidRequest(reply, 'file field is required');
|
||||
}
|
||||
|
||||
const filename = fileEntry.name || 'upload.bin';
|
||||
const mimeType = (fileEntry.type || inferMimeTypeFromFilename(filename)).trim().toLowerCase();
|
||||
if (!isSupportedProxyFileMimeType(mimeType)) {
|
||||
return invalidRequest(reply, `unsupported file mime type: ${mimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
|
||||
const purposeValue = formData.get('purpose');
|
||||
const purpose = typeof purposeValue === 'string' && purposeValue.trim().length > 0
|
||||
? purposeValue.trim()
|
||||
: 'assistants';
|
||||
const buffer = Buffer.from(await fileEntry.arrayBuffer());
|
||||
|
||||
const saved = await saveProxyFile({
|
||||
...owner,
|
||||
filename,
|
||||
mimeType,
|
||||
purpose,
|
||||
contentBase64: buffer.toString('base64'),
|
||||
});
|
||||
return reply.send(toFileObject(saved));
|
||||
});
|
||||
|
||||
app.get('/v1/files', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const files = await listProxyFilesByOwner(owner);
|
||||
return reply.send({
|
||||
object: 'list',
|
||||
data: files.map((item) => toFileObject(item)),
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
||||
app.get<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const file = await getProxyFileByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!file) return notFound(reply);
|
||||
return reply.send(toFileObject(file));
|
||||
});
|
||||
|
||||
app.get<{ Params: { fileId: string } }>('/v1/files/:fileId/content', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const file = await getProxyFileContentByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!file) return notFound(reply);
|
||||
reply.type(file.mimeType);
|
||||
reply.header('Content-Disposition', `inline; filename="${encodeURIComponent(file.filename)}"`);
|
||||
return reply.send(file.buffer);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { fileId: string } }>('/v1/files/:fileId', async (request, reply) => {
|
||||
const owner = getProxyResourceOwner(request);
|
||||
if (!owner) {
|
||||
return reply.code(401).send({ error: { message: 'Missing proxy auth context', type: 'authentication_error' } });
|
||||
}
|
||||
const deleted = await softDeleteProxyFileByPublicIdForOwner(request.params.fileId, owner);
|
||||
if (!deleted) return notFound(reply);
|
||||
return reply.send({
|
||||
id: request.params.fileId,
|
||||
object: 'file',
|
||||
deleted: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
export { filesProxyRoute } from '../../proxy-core/surfaces/filesSurface.js';
|
||||
|
||||
@@ -1,843 +1 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import { fetch } from 'undici';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import {
|
||||
buildUpstreamEndpointRequest,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from './upstreamEndpoint.js';
|
||||
import {
|
||||
geminiGenerateContentTransformer,
|
||||
} from '../../transformers/gemini/generate-content/index.js';
|
||||
import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js';
|
||||
import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
wrapGeminiCliRequest,
|
||||
} from './geminiCliCompat.js';
|
||||
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const GEMINI_MODEL_PROBES = [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-1.5-flash',
|
||||
'gemini-pro',
|
||||
];
|
||||
const GEMINI_CLI_STATIC_MODELS = [
|
||||
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
|
||||
{ name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' },
|
||||
{ name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' },
|
||||
{ name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' },
|
||||
{ name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' },
|
||||
{ name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' },
|
||||
{ name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' },
|
||||
];
|
||||
const EMPTY_PROXY_USAGE = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
|
||||
function isGeminiCliPlatform(platform: unknown): boolean {
|
||||
return String(platform || '').trim().toLowerCase() === 'gemini-cli';
|
||||
}
|
||||
|
||||
function isAntigravityPlatform(platform: unknown): boolean {
|
||||
return String(platform || '').trim().toLowerCase() === 'antigravity';
|
||||
}
|
||||
|
||||
function isInternalGeminiPlatform(platform: unknown): boolean {
|
||||
return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform);
|
||||
}
|
||||
|
||||
function buildGeminiCliActionPath(input: {
|
||||
isStreamAction: boolean;
|
||||
isCountTokensAction: boolean;
|
||||
}) {
|
||||
if (input.isCountTokensAction) return '/v1internal:countTokens';
|
||||
if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse';
|
||||
return '/v1internal:generateContent';
|
||||
}
|
||||
|
||||
function isDirectGeminiFamilyPlatform(platform: unknown): boolean {
|
||||
const normalized = String(platform || '').trim().toLowerCase();
|
||||
return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity';
|
||||
}
|
||||
|
||||
function omitGeminiCliModelField(body: unknown): Record<string, unknown> {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return {};
|
||||
const { model: _model, ...rest } = body as Record<string, unknown>;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function selectGeminiChannel(request: FastifyRequest) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
const selected = await tokenRouter.selectChannel(candidate, policy);
|
||||
if (selected) return selected;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChannelIds: number[]) {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
for (const candidate of GEMINI_MODEL_PROBES) {
|
||||
const selected = await tokenRouter.selectNextChannel(candidate, excludeChannelIds, policy);
|
||||
if (selected) return selected;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDownstreamPath(request: FastifyRequest): string {
|
||||
const rawUrl = request.raw.url || request.url || '';
|
||||
const withoutQuery = rawUrl.split('?')[0] || rawUrl;
|
||||
return withoutQuery || '/v1beta/models';
|
||||
}
|
||||
|
||||
function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string {
|
||||
const normalizedVersion = apiVersion.replace(/^\/+/, '');
|
||||
const normalizedAction = modelActionPath.replace(/^\/+/, '');
|
||||
return `/${normalizedVersion}/${normalizedAction}`;
|
||||
}
|
||||
|
||||
function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean {
|
||||
const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : [];
|
||||
const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : [];
|
||||
return supportedModels.length > 0 || allowedRouteIds.length > 0;
|
||||
}
|
||||
|
||||
function extractGeminiListedModelName(item: unknown): string {
|
||||
if (!item || typeof item !== 'object') return '';
|
||||
const rawName = typeof (item as { name?: unknown }).name === 'string'
|
||||
? (item as { name: string }).name.trim()
|
||||
: '';
|
||||
if (!rawName) return '';
|
||||
return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName;
|
||||
}
|
||||
|
||||
async function filterGeminiListedModelsForPolicy(
|
||||
payload: unknown,
|
||||
request: FastifyRequest,
|
||||
): Promise<unknown> {
|
||||
if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
if (!hasDownstreamModelRestrictions(policy)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const filteredModels: unknown[] = [];
|
||||
for (const item of (payload as { models: unknown[] }).models) {
|
||||
const modelName = extractGeminiListedModelName(item);
|
||||
if (!modelName) continue;
|
||||
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
|
||||
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
|
||||
if (decision && typeof decision.selectedChannelId !== 'number') continue;
|
||||
filteredModels.push(item);
|
||||
}
|
||||
|
||||
return {
|
||||
...(payload as Record<string, unknown>),
|
||||
models: filteredModels,
|
||||
};
|
||||
}
|
||||
|
||||
async function readRouteAwareGeminiModels(request: FastifyRequest): Promise<Array<{ name: string; displayName: string }>> {
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
|
||||
.from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(
|
||||
eq(schema.modelAvailability.available, true),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
eq(schema.sites.status, 'active'),
|
||||
))
|
||||
.all();
|
||||
const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName })
|
||||
.from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true))
|
||||
.all();
|
||||
const deduped = Array.from(new Set([
|
||||
...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean),
|
||||
...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean),
|
||||
])).sort();
|
||||
|
||||
const allowed: Array<{ name: string; displayName: string }> = [];
|
||||
for (const modelName of deduped) {
|
||||
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue;
|
||||
const decision = await tokenRouter.explainSelection?.(modelName, [], policy);
|
||||
if (decision && typeof decision.selectedChannelId !== 'number') continue;
|
||||
allowed.push({
|
||||
name: `models/${modelName}`,
|
||||
displayName: modelName,
|
||||
});
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamPath: string,
|
||||
upstreamPath: string | null,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel || modelRequested,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost: 0,
|
||||
billingDetails: null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/gemini] failed to write proxy log', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
const listModels = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion(
|
||||
request.params as { geminiApiVersion?: string } | undefined,
|
||||
);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
let lastStatus = 503;
|
||||
let lastText = 'No available channels for Gemini models';
|
||||
let lastContentType = 'application/json';
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
const selected = retryCount === 0
|
||||
? await selectGeminiChannel(request)
|
||||
: await selectNextGeminiProbeChannel(request, excludeChannelIds);
|
||||
if (!selected) {
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
try {
|
||||
if (!isDirectGeminiFamilyPlatform(selected.site.platform)) {
|
||||
let models = await readRouteAwareGeminiModels(request);
|
||||
if (models.length <= 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
models = await readRouteAwareGeminiModels(request);
|
||||
}
|
||||
return reply.code(200).send({ models });
|
||||
}
|
||||
|
||||
if (isGeminiCliPlatform(selected.site.platform)) {
|
||||
const filtered = await filterGeminiListedModelsForPolicy(
|
||||
{ models: GEMINI_CLI_STATIC_MODELS },
|
||||
request,
|
||||
);
|
||||
return reply.code(200).send(filtered);
|
||||
}
|
||||
|
||||
const upstream = await fetch(
|
||||
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
|
||||
{ method: 'GET' },
|
||||
);
|
||||
const text = await upstream.text();
|
||||
if (!upstream.ok) {
|
||||
lastStatus = upstream.status;
|
||||
lastText = text;
|
||||
lastContentType = upstream.headers.get('content-type') || 'application/json';
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const filtered = await filterGeminiListedModelsForPolicy(parsed, request);
|
||||
return reply.code(upstream.status).send(filtered);
|
||||
} catch {
|
||||
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||
}
|
||||
} catch (error) {
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
lastStatus = 502;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateContent = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options?: {
|
||||
downstreamProtocol?: 'gemini' | 'gemini-cli';
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
},
|
||||
) => {
|
||||
const downstreamProtocol = options?.downstreamProtocol || 'gemini';
|
||||
const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli';
|
||||
const cliRequestedModel = isGeminiCliDownstream
|
||||
? (typeof (request.body as Record<string, unknown> | null | undefined)?.model === 'string'
|
||||
? String((request.body as Record<string, unknown>).model).trim()
|
||||
: '')
|
||||
: '';
|
||||
const parsedPath = isGeminiCliDownstream
|
||||
? {
|
||||
apiVersion: 'v1beta',
|
||||
modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`,
|
||||
isStreamAction: options?.action === 'streamGenerateContent',
|
||||
requestedModel: cliRequestedModel,
|
||||
}
|
||||
: geminiGenerateContentTransformer.parseProxyRequestPath({
|
||||
rawUrl: request.raw.url || request.url || '',
|
||||
params: request.params as { geminiApiVersion?: string } | undefined,
|
||||
});
|
||||
const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath;
|
||||
const isCountTokensAction = isGeminiCliDownstream
|
||||
? options?.action === 'countTokens'
|
||||
: modelActionPath.endsWith(':countTokens');
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: { message: 'Gemini model path is required', type: 'invalid_request_error' },
|
||||
});
|
||||
}
|
||||
|
||||
const policy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamPath = resolveDownstreamPath(request);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
let lastStatus = 503;
|
||||
let lastText = 'No available channels for this model';
|
||||
let lastContentType = 'application/json';
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
const selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, policy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, policy);
|
||||
if (!selected) {
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const actualModel = selected.actualModel || requestedModel;
|
||||
const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest(
|
||||
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
|
||||
actualModel,
|
||||
);
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
|
||||
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
|
||||
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
|
||||
const startTime = Date.now();
|
||||
let upstreamPath = '';
|
||||
|
||||
try {
|
||||
if (isDirectGeminiFamily) {
|
||||
if (isGeminiCli && !oauth?.projectId) {
|
||||
lastStatus = 500;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: 'Gemini CLI OAuth project is missing',
|
||||
type: 'server_error',
|
||||
},
|
||||
});
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
const actualModelAction = modelActionPath.replace(
|
||||
/^models\/[^:]+/,
|
||||
`models/${actualModel}`,
|
||||
);
|
||||
upstreamPath = isInternalGemini
|
||||
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
|
||||
: resolveUpstreamPath(apiVersion, actualModelAction);
|
||||
const query = new URLSearchParams(request.query as Record<string, string>).toString();
|
||||
const requestBody = isInternalGemini
|
||||
? (
|
||||
isCountTokensAction
|
||||
? { request: normalizedBody }
|
||||
: wrapGeminiCliRequest({
|
||||
modelName: actualModel,
|
||||
projectId: oauth?.projectId || '',
|
||||
request: normalizedBody as Record<string, unknown>,
|
||||
})
|
||||
)
|
||||
: normalizedBody;
|
||||
const requestHeaders = isInternalGemini
|
||||
? {
|
||||
'Content-Type': 'application/json',
|
||||
...(isStreamAction ? { Accept: 'text/event-stream' } : {}),
|
||||
Authorization: `Bearer ${selected.tokenValue}`,
|
||||
...buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const targetUrl = isInternalGemini
|
||||
? `${selected.site.url}${upstreamPath}`
|
||||
: geminiGenerateContentTransformer.resolveActionUrl(
|
||||
selected.site.url,
|
||||
apiVersion,
|
||||
actualModelAction,
|
||||
selected.tokenValue,
|
||||
query,
|
||||
);
|
||||
const upstream = isInternalGemini
|
||||
? await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: {
|
||||
endpoint: 'chat',
|
||||
path: upstreamPath,
|
||||
headers: requestHeaders,
|
||||
body: requestBody as Record<string, unknown>,
|
||||
runtime: {
|
||||
executor: isGeminiCli ? 'gemini-cli' : 'antigravity',
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
oauthProjectId: oauth?.projectId || null,
|
||||
action: isCountTokensAction
|
||||
? 'countTokens'
|
||||
: (isStreamAction ? 'streamGenerateContent' : 'generateContent'),
|
||||
},
|
||||
},
|
||||
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
: await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
if (!upstream.ok) {
|
||||
lastStatus = upstream.status;
|
||||
lastContentType = contentType;
|
||||
lastText = await upstream.text();
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
lastStatus,
|
||||
Date.now() - startTime,
|
||||
lastText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
return reply.code(lastStatus).send(JSON.parse(lastText));
|
||||
} catch {
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
}
|
||||
|
||||
if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = upstream.status;
|
||||
reply.raw.setHeader('Content-Type', contentType || 'text/event-stream');
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
if (!reader) {
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
);
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
const decoder = new TextDecoder();
|
||||
let rest = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
const chunkText = decoder.decode(value, { stream: true });
|
||||
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
|
||||
aggregateState,
|
||||
rest + chunkText,
|
||||
);
|
||||
rest = consumed.rest;
|
||||
for (const line of consumed.lines) {
|
||||
reply.raw.write(line);
|
||||
}
|
||||
}
|
||||
const tail = decoder.decode();
|
||||
if (tail) {
|
||||
const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer(
|
||||
aggregateState,
|
||||
rest + tail,
|
||||
);
|
||||
for (const line of consumed.lines) {
|
||||
reply.raw.write(line);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
reply.raw.end();
|
||||
}
|
||||
const parsedUsage = parseProxyUsage(aggregateState);
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await upstream.text();
|
||||
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
|
||||
let parsedUsage = EMPTY_PROXY_USAGE;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream
|
||||
? unwrapGeminiCliPayload(parsed)
|
||||
: parsed;
|
||||
const responsePayload = isCountTokensAction
|
||||
? unwrappedPayload
|
||||
: geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload(
|
||||
aggregateState,
|
||||
unwrappedPayload,
|
||||
isStreamAction,
|
||||
);
|
||||
parsedUsage = parseProxyUsage(aggregateState);
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
);
|
||||
return reply.code(upstream.status).send(
|
||||
isGeminiCliDownstream && !isCountTokensAction
|
||||
? { response: responsePayload }
|
||||
: responsePayload,
|
||||
);
|
||||
} catch {
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
);
|
||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCountTokensAction) {
|
||||
lastStatus = 501;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: 'Gemini countTokens compatibility is not implemented for this upstream',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody as Record<string, unknown>,
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
});
|
||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
actualModel,
|
||||
'openai',
|
||||
requestedModel,
|
||||
);
|
||||
const buildEndpointRequest = (
|
||||
endpoint: 'chat' | 'messages' | 'responses',
|
||||
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
|
||||
) => {
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: openAiBody,
|
||||
downstreamFormat: 'openai',
|
||||
forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
endpoint,
|
||||
path: endpointRequest.path,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = createChatEndpointStrategy({
|
||||
downstreamFormat: 'openai',
|
||||
endpointCandidates,
|
||||
modelName: actualModel,
|
||||
requestedModelHint: requestedModel,
|
||||
sitePlatform: selected.site.platform,
|
||||
isStream: isStreamAction,
|
||||
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
|
||||
endpoint,
|
||||
{ forceNormalizeClaudeBody },
|
||||
),
|
||||
dispatchRequest,
|
||||
});
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover: endpointStrategy.tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
});
|
||||
if (!endpointResult.ok) {
|
||||
lastStatus = endpointResult.status;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: endpointResult.errText,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
lastStatus,
|
||||
Date.now() - startTime,
|
||||
endpointResult.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
|
||||
upstreamPath = endpointResult.upstreamPath;
|
||||
const upstream = endpointResult.upstream;
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {}
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText);
|
||||
const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({
|
||||
normalized: normalizedFinal,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const latency = Date.now() - startTime;
|
||||
await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
);
|
||||
return reply.code(upstream.status).send(
|
||||
isGeminiCliDownstream
|
||||
? { response: geminiResponse }
|
||||
: geminiResponse,
|
||||
);
|
||||
} catch (error) {
|
||||
lastStatus = 502;
|
||||
lastContentType = 'application/json';
|
||||
lastText = JSON.stringify({
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
||||
await logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
error instanceof Error ? error.message : 'Gemini upstream request failed',
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
upstreamPath || null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(lastStatus).type(lastContentType).send(lastText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply);
|
||||
const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
|
||||
downstreamProtocol: 'gemini-cli',
|
||||
action: 'generateContent',
|
||||
});
|
||||
const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
|
||||
downstreamProtocol: 'gemini-cli',
|
||||
action: 'streamGenerateContent',
|
||||
});
|
||||
const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, {
|
||||
downstreamProtocol: 'gemini-cli',
|
||||
action: 'countTokens',
|
||||
});
|
||||
|
||||
app.get('/v1beta/models', listModels);
|
||||
app.get('/gemini/:geminiApiVersion/models', listModels);
|
||||
app.post('/v1beta/models/*', generateContent);
|
||||
app.post('/gemini/:geminiApiVersion/models/*', generateContent);
|
||||
app.post('/v1internal::generateContent', geminiCliGenerateContent);
|
||||
app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent);
|
||||
app.post('/v1internal::countTokens', geminiCliCountTokens);
|
||||
}
|
||||
export { geminiProxyRoute } from '../../proxy-core/surfaces/geminiSurface.js';
|
||||
|
||||
@@ -1,114 +1,4 @@
|
||||
import type { ProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { getProxyFileByPublicIdForOwner } from '../../services/proxyFileStore.js';
|
||||
import {
|
||||
inferInputFileMimeType,
|
||||
normalizeInputFileBlock,
|
||||
toOpenAiChatFileBlock,
|
||||
toResponsesInputFileBlock,
|
||||
type NormalizedInputFile,
|
||||
} from '../../transformers/shared/inputFile.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function isSupportedFileMimeType(mimeType: string): boolean {
|
||||
return mimeType === 'application/pdf'
|
||||
|| mimeType === 'text/plain'
|
||||
|| mimeType === 'text/markdown'
|
||||
|| mimeType === 'application/json'
|
||||
|| mimeType.startsWith('image/')
|
||||
|| mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
export class ProxyInputFileResolutionError extends Error {
|
||||
statusCode: number;
|
||||
payload: unknown;
|
||||
|
||||
constructor(statusCode: number, payload: unknown) {
|
||||
super(typeof payload === 'object' && payload && 'error' in (payload as Record<string, unknown>)
|
||||
? String(((payload as { error?: { message?: unknown } }).error?.message) || 'input file resolution failed')
|
||||
: 'input file resolution failed');
|
||||
this.name = 'ProxyInputFileResolutionError';
|
||||
this.statusCode = statusCode;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function buildInvalidRequest(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(400, {
|
||||
error: {
|
||||
message,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildNotFound(message: string): ProxyInputFileResolutionError {
|
||||
return new ProxyInputFileResolutionError(404, {
|
||||
error: {
|
||||
message,
|
||||
type: 'not_found_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInputFileBlock(
|
||||
item: Record<string, unknown>,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const normalized = normalizeInputFileBlock(item);
|
||||
if (!normalized) return item;
|
||||
|
||||
let resolved: NormalizedInputFile = { ...normalized };
|
||||
|
||||
if (!resolved.fileData && resolved.fileId?.startsWith('file-metapi-')) {
|
||||
const stored = await getProxyFileByPublicIdForOwner(resolved.fileId, owner);
|
||||
if (!stored) {
|
||||
throw buildNotFound(`file not found: ${resolved.fileId}`);
|
||||
}
|
||||
resolved = {
|
||||
filename: stored.filename,
|
||||
mimeType: stored.mimeType,
|
||||
fileData: stored.contentBase64,
|
||||
};
|
||||
}
|
||||
|
||||
const inferredMimeType = inferInputFileMimeType(resolved);
|
||||
if (resolved.fileData && (!inferredMimeType || !isSupportedFileMimeType(inferredMimeType))) {
|
||||
throw buildInvalidRequest(`unsupported file mime type: ${inferredMimeType || 'application/octet-stream'}`);
|
||||
}
|
||||
if (inferredMimeType) {
|
||||
resolved.mimeType = inferredMimeType;
|
||||
}
|
||||
|
||||
const type = typeof item.type === 'string' ? item.type : '';
|
||||
if (type === 'input_file') {
|
||||
return toResponsesInputFileBlock(resolved);
|
||||
}
|
||||
if (type === 'file') {
|
||||
return toOpenAiChatFileBlock(resolved);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export async function inlineLocalInputFileReferences(
|
||||
value: unknown,
|
||||
owner: ProxyResourceOwner,
|
||||
): Promise<unknown> {
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(value.map((item) => inlineLocalInputFileReferences(item, owner)));
|
||||
}
|
||||
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type : '';
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
return resolveInputFileBlock(value, owner);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
Object.entries(value).map(async ([key, entryValue]) => [key, await inlineLocalInputFileReferences(entryValue, owner)] as const),
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
export {
|
||||
inlineLocalInputFileReferences,
|
||||
ProxyInputFileResolutionError,
|
||||
} from '../../proxy-core/surfaces/inputFilesSurface.js';
|
||||
|
||||
@@ -1,67 +1,21 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { listModelsSurface } from '../../proxy-core/surfaces/modelsSurface.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||
|
||||
function isSearchPseudoModel(modelName: string): boolean {
|
||||
const normalized = (modelName || '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return normalized === '__search' || /^__.+_search$/.test(normalized);
|
||||
}
|
||||
|
||||
export async function modelsProxyRoute(app: FastifyInstance) {
|
||||
app.get('/v1/models', async (request) => {
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
|
||||
const readModels = async () => {
|
||||
const deduped = Array.from(new Set(await tokenRouter.getAvailableModels()))
|
||||
.filter((modelName) => !isSearchPseudoModel(modelName))
|
||||
.sort();
|
||||
const allowed: string[] = [];
|
||||
for (const modelName of deduped) {
|
||||
if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) {
|
||||
continue;
|
||||
}
|
||||
const decision = await tokenRouter.explainSelection(modelName, [], downstreamPolicy);
|
||||
if (typeof decision.selectedChannelId === 'number') {
|
||||
allowed.push(modelName);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
};
|
||||
|
||||
let models = await readModels();
|
||||
if (models.length === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
models = await readModels();
|
||||
}
|
||||
|
||||
const wantsClaudeFormat = typeof request.headers['anthropic-version'] === 'string'
|
||||
|| typeof request.headers['x-api-key'] === 'string';
|
||||
if (wantsClaudeFormat) {
|
||||
const data = models.map((id) => ({
|
||||
id,
|
||||
type: 'model',
|
||||
display_name: id,
|
||||
created_at: new Date().toISOString(),
|
||||
}));
|
||||
return {
|
||||
data,
|
||||
first_id: data[0]?.id || null,
|
||||
last_id: data[data.length - 1]?.id || null,
|
||||
has_more: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data: models.map(id => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: 'metapi',
|
||||
})),
|
||||
};
|
||||
return listModelsSurface({
|
||||
downstreamPolicy,
|
||||
responseFormat: wantsClaudeFormat ? 'claude' : 'openai',
|
||||
tokenRouter,
|
||||
refreshModelsAndRebuildRoutes,
|
||||
isModelAllowed: isModelAllowedByPolicyOrAllowedRoutes,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -467,4 +467,115 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed');
|
||||
});
|
||||
|
||||
it('does not record success when a native responses stream closes before response.completed', async () => {
|
||||
fetchMock.mockResolvedValue(createSseResponse([
|
||||
'event: response.created\n',
|
||||
'data: {"type":"response.created","response":{"id":"resp_codex_truncated","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||
'event: response.output_item.added\n',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_truncated","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||
'event: response.output_text.delta\n',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_truncated","delta":"partial"}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('event: response.failed');
|
||||
expect(response.body).not.toContain('event: response.completed');
|
||||
expect(recordSuccessMock).not.toHaveBeenCalled();
|
||||
expect(recordFailureMock).toHaveBeenCalledTimes(1);
|
||||
expect(insertedProxyLogs.at(-1)).toMatchObject({
|
||||
status: 'failed',
|
||||
httpStatus: 200,
|
||||
});
|
||||
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('stream closed before response.completed');
|
||||
});
|
||||
|
||||
it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => {
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
id: 'resp_nonstream_final',
|
||||
object: 'response',
|
||||
model: 'gpt-5.4',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_nonstream_final',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'pong',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
output_text: 'pong',
|
||||
usage: { input_tokens: 3, output_tokens: 1, total_tokens: 4 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('event: response.completed');
|
||||
expect(response.body).toContain('"output_text":"pong"');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not retry or mark failure after streaming SSE success when post-stream usage accounting fails', async () => {
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
|
||||
fetchMock.mockResolvedValue(createSseResponse([
|
||||
'event: response.created\n',
|
||||
'data: {"type":"response.created","response":{"id":"resp_codex_stream_ok","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||
'event: response.output_item.added\n',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream_ok","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||
'event: response.output_text.delta\n',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream_ok","delta":"pong"}\n\n',
|
||||
'event: response.completed\n',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_codex_stream_ok","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('event: response.completed');
|
||||
expect(response.body).toContain('"id":"resp_codex_stream_ok"');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,4 +182,113 @@ describe('responses proxy compact upstream routing', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves native response.compaction payloads when the upstream compact surface closes via SSE', async () => {
|
||||
fetchMock.mockResolvedValue(new Response([
|
||||
'event: response.output_item.added',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'response.output_item.added',
|
||||
output_index: 0,
|
||||
item: {
|
||||
id: 'rs_123',
|
||||
type: 'compaction',
|
||||
encrypted_content: 'enc-compact-payload',
|
||||
},
|
||||
})}`,
|
||||
'',
|
||||
'event: response.completed',
|
||||
`data: ${JSON.stringify({
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: 'cmp_123',
|
||||
object: 'response.compaction',
|
||||
created_at: 1700000000,
|
||||
output: [
|
||||
{
|
||||
id: 'rs_123',
|
||||
type: 'compaction',
|
||||
encrypted_content: 'enc-compact-payload',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 1234,
|
||||
output_tokens: 321,
|
||||
total_tokens: 1555,
|
||||
},
|
||||
},
|
||||
})}`,
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses/compact',
|
||||
payload: {
|
||||
model: 'gpt-5.2',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
id: 'cmp_123',
|
||||
object: 'response.compaction',
|
||||
created_at: 1700000000,
|
||||
output: [
|
||||
{
|
||||
id: 'rs_123',
|
||||
type: 'compaction',
|
||||
encrypted_content: 'enc-compact-payload',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 1234,
|
||||
output_tokens: 321,
|
||||
total_tokens: 1555,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('collects final payloads when non-stream compact upstreams still respond with SSE final payloads directly', async () => {
|
||||
fetchMock.mockResolvedValue(new Response([
|
||||
'event: response.completed',
|
||||
'data: {"id":"cmp_sse_123","object":"response.compaction","input_tokens":12,"output_tokens":3,"total_tokens":15,"output":[{"id":"rs_123","type":"compaction","encrypted_content":"enc-from-sse"}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n'), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses/compact',
|
||||
payload: {
|
||||
model: 'gpt-5.2',
|
||||
input: 'hello',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
id: 'cmp_sse_123',
|
||||
object: 'response.compaction',
|
||||
input_tokens: 12,
|
||||
output_tokens: 3,
|
||||
total_tokens: 15,
|
||||
output: [
|
||||
{
|
||||
id: 'rs_123',
|
||||
type: 'compaction',
|
||||
encrypted_content: 'enc-from-sse',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,814 +1,12 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import {
|
||||
buildUpstreamEndpointRequest,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from './upstreamEndpoint.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
|
||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
hasNonImageFileInputInOpenAiBody,
|
||||
resolveResponsesBodyInputFiles,
|
||||
} from '../../services/proxyInputFileResolver.js';
|
||||
import {
|
||||
buildOauthProviderHeaders,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
} from './geminiCliCompat.js';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { handleOpenAiResponsesSurfaceRequest } from '../../proxy-core/surfaces/openAiResponsesSurface.js';
|
||||
import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function normalizeIncludeList(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function hasExplicitInclude(body: Record<string, unknown>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(body, 'include');
|
||||
}
|
||||
|
||||
function hasResponsesReasoningRequest(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
|
||||
return relevantKeys.some((key) => {
|
||||
const entry = value[key];
|
||||
if (typeof entry === 'string') return entry.trim().length > 0;
|
||||
return entry !== undefined && entry !== null;
|
||||
});
|
||||
}
|
||||
|
||||
function carriesResponsesReasoningContinuity(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => carriesResponsesReasoningContinuity(item));
|
||||
}
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : '';
|
||||
if (type === 'reasoning') {
|
||||
if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value.summary) && value.summary.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return carriesResponsesReasoningContinuity(value.input)
|
||||
|| carriesResponsesReasoningContinuity(value.content);
|
||||
}
|
||||
|
||||
function wantsNativeResponsesReasoning(body: unknown): boolean {
|
||||
if (!isRecord(body)) return false;
|
||||
const include = normalizeIncludeList(body.include);
|
||||
if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) {
|
||||
return true;
|
||||
}
|
||||
if (carriesResponsesReasoningContinuity(body.input)) {
|
||||
return true;
|
||||
}
|
||||
if (hasExplicitInclude(body)) {
|
||||
return false;
|
||||
}
|
||||
return hasResponsesReasoningRequest(body.reasoning);
|
||||
}
|
||||
|
||||
function carriesResponsesFileUrlInput(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => carriesResponsesFileUrlInput(item));
|
||||
}
|
||||
if (!isRecord(value)) return false;
|
||||
|
||||
const normalizedFile = normalizeInputFileBlock(value);
|
||||
if (normalizedFile?.fileUrl) return true;
|
||||
|
||||
return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry));
|
||||
}
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
|
||||
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
||||
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
||||
? body.prompt_cache_key.trim()
|
||||
: '';
|
||||
return promptCacheKey || null;
|
||||
}
|
||||
|
||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
ensureResponsesWebsocketTransport(app);
|
||||
const handleResponsesRequest = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
downstreamPath: string,
|
||||
) => {
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body,
|
||||
});
|
||||
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
|
||||
request.headers as Record<string, unknown>,
|
||||
);
|
||||
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
|
||||
defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (parsedRequestEnvelope.error) {
|
||||
return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload);
|
||||
}
|
||||
const requestEnvelope = parsedRequestEnvelope.value!;
|
||||
const requestedModel = requestEnvelope.model;
|
||||
const isStream = requestEnvelope.stream;
|
||||
const isCompactRequest = downstreamPath === '/v1/responses/compact';
|
||||
if (isCompactRequest && isStream) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'stream is not supported on /v1/responses/compact',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex';
|
||||
const owner = getProxyResourceOwner(request);
|
||||
let normalizedResponsesBody: Record<string, unknown> = {
|
||||
...requestEnvelope.parsed.normalizedBody,
|
||||
model: modelName,
|
||||
stream: isStream,
|
||||
};
|
||||
if (body.generate === false) {
|
||||
normalizedResponsesBody.generate = false;
|
||||
}
|
||||
if (owner) {
|
||||
try {
|
||||
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyInputFileResolutionError) {
|
||||
return reply.code(error.statusCode).send(error.payload);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(
|
||||
normalizedResponsesBody,
|
||||
modelName,
|
||||
isStream,
|
||||
{ defaultEncryptedReasoningInclude },
|
||||
);
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
||||
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
|
||||
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'Responses input_file.file_url requires an upstream /v1/responses endpoint; current site only supports /v1/messages.',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||
{
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
},
|
||||
modelName,
|
||||
'responses',
|
||||
requestedModel,
|
||||
{
|
||||
hasNonImageFileInput,
|
||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||
},
|
||||
);
|
||||
if (requiresNativeResponsesFileUrl) {
|
||||
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
|
||||
}
|
||||
if (endpointCandidates.length === 0) {
|
||||
endpointCandidates.push('responses', 'chat', 'messages');
|
||||
}
|
||||
const buildProviderHeaders = () => (
|
||||
buildOauthProviderHeaders({
|
||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
})
|
||||
);
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody);
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: upstreamStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: openAiBody,
|
||||
downstreamFormat: 'responses',
|
||||
responsesOriginalBody: normalizedResponsesBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildProviderHeaders(),
|
||||
codexExplicitSessionId,
|
||||
});
|
||||
const upstreamPath = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
? `${endpointRequest.path}/compact`
|
||||
: endpointRequest.path
|
||||
);
|
||||
return {
|
||||
endpoint,
|
||||
path: upstreamPath,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream: isStream || isCodexSite,
|
||||
requiresNativeResponsesFileUrl,
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
upstreamPath: refreshedRequest.path,
|
||||
};
|
||||
}
|
||||
ctx.request = refreshedRequest;
|
||||
ctx.response = refreshedResponse;
|
||||
ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error');
|
||||
} catch {
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
}
|
||||
}
|
||||
return endpointStrategy.tryRecover(ctx);
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
ctx.response.status,
|
||||
Date.now() - startTime,
|
||||
ctx.errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpointResult.ok) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
const rawErrText = endpointResult.rawErrText || errText;
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
status,
|
||||
Date.now() - startTime,
|
||||
errText,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: selected.account.id,
|
||||
statusCode: status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
accountId: selected.account.id,
|
||||
username: selected.account.username,
|
||||
siteName: selected.site.name,
|
||||
detail: `HTTP ${status}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRetryProxyRequest(status, errText) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: `upstream returned HTTP ${status}`,
|
||||
});
|
||||
return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
const upstream = endpointResult.upstream;
|
||||
const successfulUpstreamPath = endpointResult.upstreamPath;
|
||||
|
||||
if (isStream) {
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
const startSseResponse = () => {
|
||||
reply.hijack();
|
||||
reply.raw.statusCode = 200;
|
||||
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
reply.raw.setHeader('Connection', 'keep-alive');
|
||||
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
||||
};
|
||||
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
const rawText = await upstream.text();
|
||||
let upstreamData: unknown = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const latency = Date.now() - startTime;
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
}
|
||||
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
modelName,
|
||||
rawText,
|
||||
);
|
||||
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
|
||||
upstreamPayload: upstreamData,
|
||||
normalized,
|
||||
usage: parsedUsage,
|
||||
serializationMode: 'response',
|
||||
});
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
|
||||
const streamPayload = isRecord(downstreamData) ? downstreamData : {
|
||||
id: `resp_${Date.now()}`,
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
model: modelName,
|
||||
output: [],
|
||||
output_text: '',
|
||||
usage: {
|
||||
input_tokens: parsedUsage.promptTokens,
|
||||
output_tokens: parsedUsage.completionTokens,
|
||||
total_tokens: parsedUsage.totalTokens,
|
||||
},
|
||||
};
|
||||
const createdPayload = {
|
||||
...streamPayload,
|
||||
status: 'in_progress',
|
||||
output: [],
|
||||
output_text: '',
|
||||
};
|
||||
|
||||
startSseResponse();
|
||||
reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`);
|
||||
reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`);
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
startSseResponse();
|
||||
|
||||
const upstreamReader = upstream.body?.getReader();
|
||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||
? createGeminiCliStreamReader(upstreamReader)
|
||||
: upstreamReader;
|
||||
let parsedUsage: UsageSummary = {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
let rawText = '';
|
||||
const decoder = new TextDecoder();
|
||||
const reader = baseReader
|
||||
? {
|
||||
async read() {
|
||||
const result = await baseReader.read();
|
||||
if (result.value) {
|
||||
rawText += decoder.decode(result.value, { stream: true });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async cancel(reason?: unknown) {
|
||||
return baseReader.cancel(reason);
|
||||
},
|
||||
releaseLock() {
|
||||
return baseReader.releaseLock();
|
||||
},
|
||||
}
|
||||
: baseReader;
|
||||
const writeLines = (lines: string[]) => {
|
||||
for (const line of lines) reply.raw.write(line);
|
||||
};
|
||||
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
||||
modelName,
|
||||
successfulUpstreamPath,
|
||||
strictTerminalEvents: Object.entries(request.headers as Record<string, unknown>)
|
||||
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
||||
&& String(rawValue).trim() === '1'),
|
||||
getUsage: () => parsedUsage,
|
||||
onParsedPayload: (payload) => {
|
||||
if (payload && typeof payload === 'object') {
|
||||
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
|
||||
}
|
||||
},
|
||||
writeLines,
|
||||
writeRaw: (chunk) => {
|
||||
reply.raw.write(chunk);
|
||||
},
|
||||
});
|
||||
const streamResult = await streamSession.run(reader, reply.raw);
|
||||
rawText += decoder.decode();
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
if (streamResult.status === 'failed') {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
200,
|
||||
latency,
|
||||
streamResult.errorMessage,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once SSE has been hijacked and bytes may already be on the wire, we
|
||||
// must not attempt to convert stream failures into a fresh HTTP error
|
||||
// response or retry on another channel. Responses stream failures are
|
||||
// handled in-band by the proxy stream session.
|
||||
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
|
||||
let rawText = '';
|
||||
let upstreamData: unknown;
|
||||
if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) {
|
||||
const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName);
|
||||
rawText = collected.rawText;
|
||||
upstreamData = collected.payload;
|
||||
} else {
|
||||
rawText = await upstream.text();
|
||||
upstreamData = rawText;
|
||||
try {
|
||||
upstreamData = JSON.parse(rawText);
|
||||
} catch {
|
||||
upstreamData = rawText;
|
||||
}
|
||||
}
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||
upstreamData = unwrapGeminiCliPayload(upstreamData);
|
||||
}
|
||||
const latency = Date.now() - startTime;
|
||||
const parsedUsage = parseProxyUsage(upstreamData);
|
||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||
if (failure) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
failure.status,
|
||||
latency,
|
||||
failure.reason,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
parsedUsage.promptTokens,
|
||||
parsedUsage.completionTokens,
|
||||
parsedUsage.totalTokens,
|
||||
0,
|
||||
null,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: failure.reason,
|
||||
});
|
||||
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
|
||||
}
|
||||
const normalized = openAiResponsesTransformer.transformFinalResponse(
|
||||
upstreamData,
|
||||
modelName,
|
||||
rawText,
|
||||
);
|
||||
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
|
||||
upstreamPayload: upstreamData,
|
||||
normalized,
|
||||
usage: parsedUsage,
|
||||
serializationMode: isCompactRequest ? 'compact' : 'response',
|
||||
});
|
||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
tokenValue: selected.tokenValue,
|
||||
tokenName: selected.tokenName,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
requestStartedAtMs: startTime,
|
||||
requestEndedAtMs: startTime + latency,
|
||||
localLatencyMs: latency,
|
||||
usage: {
|
||||
promptTokens: parsedUsage.promptTokens,
|
||||
completionTokens: parsedUsage.completionTokens,
|
||||
totalTokens: parsedUsage.totalTokens,
|
||||
},
|
||||
});
|
||||
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||
site: selected.site,
|
||||
account: selected.account,
|
||||
modelName: selected.actualModel || requestedModel,
|
||||
parsedUsage,
|
||||
resolvedUsage,
|
||||
});
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||
recordDownstreamCostUsage(request, estimatedCost);
|
||||
logProxy(
|
||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||
successfulUpstreamPath,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return reply.send(downstreamData);
|
||||
} catch (err: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
err.message,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: err.message || 'network failure',
|
||||
});
|
||||
return reply.code(502).send({
|
||||
error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleResponsesRequest(request, reply, '/v1/responses'));
|
||||
handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses'));
|
||||
app.get('/v1/responses', async (_request: FastifyRequest, reply: FastifyReply) =>
|
||||
reply.code(426).send({
|
||||
error: {
|
||||
@@ -817,59 +15,5 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
},
|
||||
}));
|
||||
app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleResponsesRequest(request, reply, '/v1/responses/compact'));
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
httpStatus: number,
|
||||
latencyMs: number,
|
||||
errorMessage: string | null,
|
||||
retryCount: number,
|
||||
downstreamPath: string,
|
||||
promptTokens = 0,
|
||||
completionTokens = 0,
|
||||
totalTokens = 0,
|
||||
estimatedCost = 0,
|
||||
billingDetails: unknown = null,
|
||||
upstreamPath: string | null = null,
|
||||
clientContext: DownstreamClientContext | null = null,
|
||||
downstreamApiKeyId: number | null = null,
|
||||
) {
|
||||
try {
|
||||
const createdAt = formatUtcSqlDateTime(new Date());
|
||||
const normalizedErrorMessage = composeProxyLogMessage({
|
||||
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||
? clientContext.clientKind
|
||||
: null,
|
||||
sessionId: clientContext?.sessionId || null,
|
||||
traceHint: clientContext?.traceHint || null,
|
||||
downstreamPath,
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
||||
modelRequested,
|
||||
modelActual: selected.actualModel,
|
||||
status,
|
||||
httpStatus,
|
||||
latencyMs,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
||||
errorMessage: normalizedErrorMessage,
|
||||
retryCount,
|
||||
createdAt,
|
||||
}).run();
|
||||
} catch (error) {
|
||||
console.warn('[proxy/responses] failed to write proxy log', error);
|
||||
}
|
||||
handleOpenAiResponsesSurfaceRequest(request, reply, '/v1/responses/compact'));
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ describe('responses websocket transport', () => {
|
||||
|
||||
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
||||
await waitForSocketOpen(socket);
|
||||
const messagesPromise = waitForSocketMessages(socket, 3, 400);
|
||||
const messagesPromise = waitForSocketMessages(socket, 6, 400);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
@@ -546,9 +546,12 @@ describe('responses websocket transport', () => {
|
||||
expect(messages.map((message) => message.type)).toEqual([
|
||||
'response.created',
|
||||
'response.output_text.delta',
|
||||
'error',
|
||||
'response.output_text.done',
|
||||
'response.content_part.done',
|
||||
'response.output_item.done',
|
||||
'response.failed',
|
||||
]);
|
||||
expect(messages[2]?.status).toBe(408);
|
||||
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
|
||||
expect(messages[5]?.response?.status).toBe('failed');
|
||||
expect(messages[5]?.error?.message).toContain('stream closed before response.completed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||
|
||||
describe('collectResponsesFinalPayloadFromSse', () => {
|
||||
it('treats event:error payloads as upstream failures', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
return [
|
||||
'event: error',
|
||||
'data: {"error":{"message":"quota exceeded"},"type":"error"}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.rejects
|
||||
.toThrow('quota exceeded');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,14 @@ function getResponsesFailureMessage(payload: Record<string, unknown>): string {
|
||||
return 'upstream stream failed';
|
||||
}
|
||||
|
||||
function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boolean {
|
||||
return (
|
||||
payload.object === 'response.compaction'
|
||||
|| Array.isArray(payload.output)
|
||||
|| Object.prototype.hasOwnProperty.call(payload, 'output_text')
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectResponsesFinalPayloadFromSse(
|
||||
upstream: { text(): Promise<string> },
|
||||
modelName: string,
|
||||
@@ -42,18 +50,36 @@ export async function collectResponsesFinalPayloadFromSse(
|
||||
};
|
||||
let completedPayload: Record<string, unknown> | null = null;
|
||||
|
||||
const captureCompletedPayload = (lines: string[]) => {
|
||||
const captureCompletedPayloadFromEvent = (
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => {
|
||||
if (completedPayload) return;
|
||||
if (eventType === 'response.failed' || eventType === 'response.incomplete' || eventType === 'error') {
|
||||
throw new Error(getResponsesFailureMessage(payload));
|
||||
}
|
||||
if (eventType !== 'response.completed') {
|
||||
return;
|
||||
}
|
||||
if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) {
|
||||
completedPayload = payload.response;
|
||||
return;
|
||||
}
|
||||
if (hasCompleteFinalResponsesPayload(payload)) {
|
||||
completedPayload = payload;
|
||||
}
|
||||
};
|
||||
|
||||
const captureCompletedPayloadFromLines = (lines: string[]) => {
|
||||
if (completedPayload) return;
|
||||
const parsed = openAiResponsesTransformer.pullSseEvents(lines.join(''));
|
||||
for (const event of parsed.events) {
|
||||
if (event.data === '[DONE]') continue;
|
||||
const payload = parseResponsesSsePayload(event.data);
|
||||
if (!payload) continue;
|
||||
if (payload.type === 'response.failed') {
|
||||
throw new Error(getResponsesFailureMessage(payload));
|
||||
}
|
||||
if (payload.type === 'response.completed' && isRecord(payload.response)) {
|
||||
completedPayload = payload.response;
|
||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||
captureCompletedPayloadFromEvent(payloadType || event.event, payload);
|
||||
if (completedPayload) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -64,13 +90,15 @@ export async function collectResponsesFinalPayloadFromSse(
|
||||
const payload = parseResponsesSsePayload(event.data);
|
||||
if (!payload) continue;
|
||||
|
||||
if (payload.type === 'response.failed') {
|
||||
throw new Error(getResponsesFailureMessage(payload));
|
||||
}
|
||||
|
||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||
const eventType = payloadType || event.event;
|
||||
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
|
||||
captureCompletedPayloadFromEvent(eventType, payload);
|
||||
if (completedPayload) {
|
||||
continue;
|
||||
}
|
||||
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName);
|
||||
captureCompletedPayload(openAiResponsesTransformer.aggregator.serialize({
|
||||
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
|
||||
state: aggregateState,
|
||||
streamContext,
|
||||
event: normalizedEvent,
|
||||
@@ -79,7 +107,7 @@ export async function collectResponsesFinalPayloadFromSse(
|
||||
}
|
||||
|
||||
if (!completedPayload) {
|
||||
captureCompletedPayload(openAiResponsesTransformer.aggregator.complete(
|
||||
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.complete(
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
|
||||
@@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetch } from 'undici';
|
||||
import type { BuiltEndpointRequest } from './endpointFlow.js';
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
vi.mock('undici', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('undici')>();
|
||||
return {
|
||||
...actual,
|
||||
fetch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
|
||||
|
||||
@@ -1,346 +1,24 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
|
||||
import type { BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_BASE_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
] as const;
|
||||
|
||||
type RuntimeDispatchInput = {
|
||||
siteUrl: string;
|
||||
request: BuiltEndpointRequest;
|
||||
targetUrl?: string;
|
||||
buildInit: (requestUrl: string, request: BuiltEndpointRequest) => Promise<UndiciRequestInit> | UndiciRequestInit;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function withRequestBody(
|
||||
request: BuiltEndpointRequest,
|
||||
body: Record<string, unknown>,
|
||||
headers?: Record<string, string>,
|
||||
): BuiltEndpointRequest {
|
||||
return {
|
||||
...request,
|
||||
headers: headers ? { ...headers } : { ...request.headers },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async function performFetch(
|
||||
input: RuntimeDispatchInput,
|
||||
request: BuiltEndpointRequest,
|
||||
requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path),
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const init = await input.buildInit(requestUrl, request);
|
||||
return fetch(requestUrl, init);
|
||||
}
|
||||
|
||||
async function materializeErrorResponse(
|
||||
response: Awaited<ReturnType<typeof fetch>>,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
if (response.ok) return response;
|
||||
const text = await response.text().catch(() => '');
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>;
|
||||
}
|
||||
|
||||
function parseGeminiCliPreviewFallbackOrder(modelName: string): string[] {
|
||||
switch (modelName.trim()) {
|
||||
case 'gemini-2.5-pro':
|
||||
return [];
|
||||
case 'gemini-2.5-flash':
|
||||
return [];
|
||||
case 'gemini-2.5-flash-lite':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined {
|
||||
const raw = asTrimmedString(userAgent);
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `);
|
||||
}
|
||||
|
||||
function buildGeminiCliAttemptRequest(request: BuiltEndpointRequest, modelName: string): BuiltEndpointRequest {
|
||||
const body = structuredClone(request.body);
|
||||
const action = request.runtime?.action;
|
||||
if (action === 'countTokens') {
|
||||
delete body.model;
|
||||
delete body.project;
|
||||
} else {
|
||||
body.model = modelName;
|
||||
}
|
||||
const headers = { ...request.headers };
|
||||
const nextUserAgent = replaceGeminiCliModelInUserAgent(
|
||||
headers['User-Agent'] || headers['user-agent'],
|
||||
modelName,
|
||||
);
|
||||
if (nextUserAgent) {
|
||||
headers['User-Agent'] = nextUserAgent;
|
||||
delete headers['user-agent'];
|
||||
}
|
||||
return withRequestBody(request, body, headers);
|
||||
}
|
||||
|
||||
function antigravityRequestType(modelName: string): 'image_gen' | 'agent' {
|
||||
return modelName.includes('image') ? 'image_gen' : 'agent';
|
||||
}
|
||||
|
||||
function generateAntigravityProjectId(): string {
|
||||
const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold'];
|
||||
const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge'];
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful';
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal';
|
||||
const suffix = Math.floor(100000 + Math.random() * 900000);
|
||||
return `${adjective}-${noun}-${suffix}`;
|
||||
}
|
||||
|
||||
function extractFirstUserText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return '';
|
||||
for (const content of value) {
|
||||
if (!content || typeof content !== 'object' || Array.isArray(content)) continue;
|
||||
const record = content as Record<string, unknown>;
|
||||
if (asTrimmedString(record.role) !== 'user') continue;
|
||||
const parts = Array.isArray(record.parts) ? record.parts : [];
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== 'object' || Array.isArray(part)) continue;
|
||||
const text = asTrimmedString((part as Record<string, unknown>).text);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function generateStableAntigravitySessionId(payload: Record<string, unknown>): string {
|
||||
const firstUserText = extractFirstUserText(
|
||||
payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request)
|
||||
? (payload.request as Record<string, unknown>).contents
|
||||
: undefined,
|
||||
);
|
||||
if (!firstUserText) {
|
||||
return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`;
|
||||
}
|
||||
const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16);
|
||||
const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff');
|
||||
return `-${bigint.toString()}`;
|
||||
}
|
||||
|
||||
function renameParametersJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => renameParametersJsonSchema(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(input)) {
|
||||
const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key;
|
||||
output[nextKey] = renameParametersJsonSchema(entry);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function deleteNestedMaxOutputTokens(payload: Record<string, unknown>): void {
|
||||
const request = payload.request;
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) return;
|
||||
const generationConfig = (request as Record<string, unknown>).generationConfig;
|
||||
if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return;
|
||||
delete (generationConfig as Record<string, unknown>).maxOutputTokens;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeBody(
|
||||
originalBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
action?: NonNullable<BuiltEndpointRequest['runtime']>['action'],
|
||||
): Record<string, unknown> {
|
||||
const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record<string, unknown>;
|
||||
if (action === 'countTokens') {
|
||||
return payload;
|
||||
}
|
||||
const requestType = antigravityRequestType(modelName);
|
||||
const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId();
|
||||
|
||||
payload.model = modelName;
|
||||
payload.project = projectId;
|
||||
payload.userAgent = 'antigravity';
|
||||
payload.requestType = requestType;
|
||||
payload.requestId = requestType === 'image_gen'
|
||||
? `image_gen/${Date.now()}/${randomUUID()}/12`
|
||||
: `agent-${randomUUID()}`;
|
||||
|
||||
const request = payload.request;
|
||||
if (request && typeof request === 'object' && !Array.isArray(request)) {
|
||||
delete (request as Record<string, unknown>).safetySettings;
|
||||
if (requestType !== 'image_gen') {
|
||||
(request as Record<string, unknown>).sessionId = generateStableAntigravitySessionId(payload);
|
||||
}
|
||||
if (modelName.includes('claude')) {
|
||||
const toolConfig = (
|
||||
(request as Record<string, unknown>).toolConfig
|
||||
&& typeof (request as Record<string, unknown>).toolConfig === 'object'
|
||||
&& !Array.isArray((request as Record<string, unknown>).toolConfig)
|
||||
)
|
||||
? (request as Record<string, unknown>).toolConfig as Record<string, unknown>
|
||||
: (((request as Record<string, unknown>).toolConfig = {}) as Record<string, unknown>);
|
||||
toolConfig.functionCallingConfig = { mode: 'VALIDATED' };
|
||||
} else {
|
||||
deleteNestedMaxOutputTokens(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function antigravityShouldRetryNoCapacity(
|
||||
status: number,
|
||||
responseText: string,
|
||||
): boolean {
|
||||
if (status !== 503) return false;
|
||||
return responseText.toLowerCase().includes('no capacity available');
|
||||
}
|
||||
|
||||
function antigravityNoCapacityRetryDelay(attempt: number): number {
|
||||
const delay = Math.min((attempt + 1) * 250, 2000);
|
||||
return delay;
|
||||
}
|
||||
|
||||
async function waitMs(ms: number): Promise<void> {
|
||||
if (ms <= 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function dispatchGeminiCliRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const models = [baseModel, ...parseGeminiCliPreviewFallbackOrder(baseModel)].filter(Boolean);
|
||||
let lastResponse: Awaited<ReturnType<typeof fetch>> | null = null;
|
||||
|
||||
for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) {
|
||||
const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName);
|
||||
const response = await performFetch(input, attemptRequest);
|
||||
if (response.ok) return response;
|
||||
|
||||
if (response.status === 429) {
|
||||
lastResponse = await materializeErrorResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
return materializeErrorResponse(response);
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, input.request);
|
||||
}
|
||||
|
||||
async function dispatchCodexRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
return performFetch(input, input.request);
|
||||
}
|
||||
|
||||
async function dispatchClaudeRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
return performFetch(input, input.request);
|
||||
}
|
||||
|
||||
async function dispatchAntigravityRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const runtimeBody = buildAntigravityRuntimeBody(
|
||||
input.request.body,
|
||||
modelName,
|
||||
input.request.runtime?.action,
|
||||
);
|
||||
const baseAttempts = 3;
|
||||
let lastResponse: Awaited<ReturnType<typeof fetch>> | null = null;
|
||||
|
||||
attemptLoop:
|
||||
for (let attempt = 0; attempt < baseAttempts; attempt += 1) {
|
||||
for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) {
|
||||
const requestUrl = `${baseUrl}${input.request.path}`;
|
||||
const minimalHeaders: Record<string, string> = {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
};
|
||||
let response: Awaited<ReturnType<typeof fetch>>;
|
||||
try {
|
||||
response = await performFetch(
|
||||
input,
|
||||
withRequestBody(input.request, runtimeBody, minimalHeaders),
|
||||
requestUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
continue attemptLoop;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) return response;
|
||||
|
||||
const errorResponse = await materializeErrorResponse(response);
|
||||
const errorText = await errorResponse.text().catch(() => '');
|
||||
lastResponse = new Response(errorText, {
|
||||
status: errorResponse.status,
|
||||
headers: errorResponse.headers,
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>;
|
||||
|
||||
if (errorResponse.status === 429) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
await waitMs(antigravityNoCapacityRetryDelay(attempt));
|
||||
continue attemptLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
}));
|
||||
}
|
||||
import { antigravityExecutor } from '../../proxy-core/executors/antigravityExecutor.js';
|
||||
import { claudeExecutor } from '../../proxy-core/executors/claudeExecutor.js';
|
||||
import { codexExecutor } from '../../proxy-core/executors/codexExecutor.js';
|
||||
import { geminiCliExecutor } from '../../proxy-core/executors/geminiCliExecutor.js';
|
||||
import type { RuntimeDispatchInput, RuntimeResponse } from '../../proxy-core/executors/types.js';
|
||||
|
||||
export async function dispatchRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
): Promise<RuntimeResponse> {
|
||||
const executor = input.request.runtime?.executor || 'default';
|
||||
if (executor === 'codex') {
|
||||
return dispatchCodexRuntimeRequest(input);
|
||||
return codexExecutor.dispatch(input);
|
||||
}
|
||||
if (executor === 'claude') {
|
||||
return dispatchClaudeRuntimeRequest(input);
|
||||
return claudeExecutor.dispatch(input);
|
||||
}
|
||||
if (executor === 'gemini-cli') {
|
||||
return dispatchGeminiCliRuntimeRequest(input);
|
||||
return geminiCliExecutor.dispatch(input);
|
||||
}
|
||||
if (executor === 'antigravity') {
|
||||
return dispatchAntigravityRuntimeRequest(input);
|
||||
return antigravityExecutor.dispatch(input);
|
||||
}
|
||||
return performFetch(input, input.request);
|
||||
return codexExecutor.dispatch(input);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
|
||||
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from '../../transformers/anthropic/messages/conversion.js';
|
||||
import {
|
||||
buildGeminiGenerateContentRequestFromOpenAi,
|
||||
wrapGeminiCliRequest,
|
||||
} from './geminiCliCompat.js';
|
||||
export {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
@@ -790,6 +790,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
};
|
||||
} {
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
const providerProfile = resolveProviderProfile(sitePlatform);
|
||||
const isClaudeUpstream = sitePlatform === 'claude';
|
||||
const isGeminiUpstream = sitePlatform === 'gemini';
|
||||
const isGeminiCliUpstream = sitePlatform === 'gemini-cli';
|
||||
@@ -886,10 +887,6 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
};
|
||||
|
||||
if (isInternalGeminiUpstream) {
|
||||
const projectId = asTrimmedString(input.oauthProjectId);
|
||||
if (isGeminiCliUpstream && !projectId) {
|
||||
throw new Error('gemini-cli oauth project id missing');
|
||||
}
|
||||
const instructions = (
|
||||
input.downstreamFormat === 'responses'
|
||||
&& typeof input.responsesOriginalBody?.instructions === 'string'
|
||||
@@ -901,27 +898,22 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
modelName: input.modelName,
|
||||
instructions,
|
||||
});
|
||||
const headers = isGeminiCliUpstream
|
||||
? buildGeminiCliRuntimeHeaders({
|
||||
baseHeaders: commonHeaders,
|
||||
providerHeaders: input.providerHeaders,
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
})
|
||||
: buildAntigravityRuntimeHeaders({
|
||||
baseHeaders: commonHeaders,
|
||||
stream: input.stream,
|
||||
});
|
||||
return {
|
||||
path: resolveEndpointPath(input.endpoint),
|
||||
headers,
|
||||
body: wrapGeminiCliRequest({
|
||||
modelName: input.modelName,
|
||||
projectId: projectId || '',
|
||||
request: geminiRequest,
|
||||
}) as Record<string, unknown>,
|
||||
runtime,
|
||||
};
|
||||
if (!providerProfile) {
|
||||
throw new Error(`missing provider profile for platform: ${sitePlatform}`);
|
||||
}
|
||||
return providerProfile.prepareRequest({
|
||||
endpoint: input.endpoint,
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
tokenValue: input.tokenValue,
|
||||
oauthProvider: input.oauthProvider,
|
||||
oauthProjectId: input.oauthProjectId,
|
||||
sitePlatform,
|
||||
baseHeaders: commonHeaders,
|
||||
providerHeaders: input.providerHeaders,
|
||||
body: geminiRequest,
|
||||
action: input.stream ? 'streamGenerateContent' : 'generateContent',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.endpoint === 'messages') {
|
||||
@@ -961,6 +953,21 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
|
||||
);
|
||||
|
||||
if (providerProfile?.id === 'claude') {
|
||||
return providerProfile.prepareRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
tokenValue: input.tokenValue,
|
||||
oauthProvider: input.oauthProvider,
|
||||
oauthProjectId: input.oauthProjectId,
|
||||
sitePlatform,
|
||||
baseHeaders: commonHeaders,
|
||||
claudeHeaders,
|
||||
body: sanitizedBody,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = buildClaudeRuntimeHeaders({
|
||||
baseHeaders: commonHeaders,
|
||||
claudeHeaders,
|
||||
@@ -1009,6 +1016,26 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
sitePlatform,
|
||||
);
|
||||
|
||||
if (providerProfile?.id === 'codex') {
|
||||
return providerProfile.prepareRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
tokenValue: input.tokenValue,
|
||||
oauthProvider: input.oauthProvider,
|
||||
oauthProjectId: input.oauthProjectId,
|
||||
sitePlatform,
|
||||
baseHeaders: {
|
||||
...commonHeaders,
|
||||
...responsesHeaders,
|
||||
},
|
||||
providerHeaders: input.providerHeaders,
|
||||
codexSessionCacheKey: input.codexSessionCacheKey,
|
||||
codexExplicitSessionId: input.codexExplicitSessionId,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = sitePlatform === 'codex'
|
||||
? buildCodexRuntimeHeaders({
|
||||
baseHeaders: {
|
||||
@@ -1078,9 +1105,7 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
|
||||
};
|
||||
} {
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude';
|
||||
const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders);
|
||||
const anthropicVersion = claudeHeaders['anthropic-version'] || '2023-06-01';
|
||||
const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({
|
||||
...input.claudeBody,
|
||||
model: input.modelName,
|
||||
@@ -1089,23 +1114,33 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
|
||||
delete sanitizedBody.max_tokens;
|
||||
delete sanitizedBody.maxTokens;
|
||||
delete sanitizedBody.stream;
|
||||
const providerProfile = resolveProviderProfile(sitePlatform);
|
||||
if (providerProfile?.id !== 'claude') {
|
||||
throw new Error(`missing claude provider profile for platform: ${sitePlatform || 'unknown'}`);
|
||||
}
|
||||
|
||||
const headers = buildClaudeRuntimeHeaders({
|
||||
const prepared = providerProfile.prepareRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: input.modelName,
|
||||
stream: false,
|
||||
tokenValue: input.tokenValue,
|
||||
oauthProvider: input.oauthProvider,
|
||||
sitePlatform,
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
claudeHeaders,
|
||||
anthropicVersion,
|
||||
stream: false,
|
||||
isClaudeOauthUpstream,
|
||||
tokenValue: input.tokenValue,
|
||||
extraBetas: betas,
|
||||
claudeHeaders: {
|
||||
...claudeHeaders,
|
||||
...(betas.length > 0 ? { 'anthropic-beta': betas.join(',') } : {}),
|
||||
},
|
||||
body: sanitizedBody,
|
||||
action: 'countTokens',
|
||||
});
|
||||
|
||||
return {
|
||||
path: '/v1/messages/count_tokens?beta=true',
|
||||
headers,
|
||||
body: sanitizedBody,
|
||||
path: prepared.path,
|
||||
headers: prepared.headers,
|
||||
body: prepared.body,
|
||||
runtime: {
|
||||
executor: 'claude',
|
||||
modelName: input.modelName,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
@@ -148,6 +148,8 @@ describe('backupService', () => {
|
||||
|
||||
expect(result.allImported).toBe(true);
|
||||
expect(result.sections.accounts).toBe(true);
|
||||
expect(result.summary).toBeUndefined();
|
||||
expect(result.warnings).toBeUndefined();
|
||||
|
||||
const restoredSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
|
||||
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
@@ -235,6 +237,290 @@ describe('backupService', () => {
|
||||
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('imports ALL-API-Hub V2 backups into native offline connections and summaries', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockImplementation(async () => {
|
||||
throw new Error('network access should not happen during offline import');
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
version: '2.0',
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'managed-account',
|
||||
site_url: 'https://newapi.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Managed Site',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 7788,
|
||||
username: 'managed-user',
|
||||
access_token: 'managed-session-token',
|
||||
quota: 100000,
|
||||
today_quota_consumption: 50000,
|
||||
},
|
||||
checkIn: {
|
||||
autoCheckInEnabled: true,
|
||||
},
|
||||
created_at: '2026-02-01T00:00:00.000Z',
|
||||
updated_at: '2026-02-02T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'cookie-account',
|
||||
site_url: 'https://onehub.example.com',
|
||||
site_type: 'one-hub',
|
||||
site_name: 'Cookie Site',
|
||||
username: 'cookie-user',
|
||||
authType: 'cookie',
|
||||
cookieAuth: {
|
||||
sessionCookie: 'sid=cookie-session',
|
||||
},
|
||||
checkIn: {
|
||||
autoCheckInEnabled: false,
|
||||
},
|
||||
created_at: '2026-02-03T00:00:00.000Z',
|
||||
updated_at: '2026-02-04T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'direct-openai-account',
|
||||
site_url: 'https://api.openai.com',
|
||||
site_type: 'openai',
|
||||
site_name: 'OpenAI Direct',
|
||||
username: 'openai-account',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'openai-account',
|
||||
access_token: 'sk-openai-account',
|
||||
},
|
||||
created_at: '2026-02-05T00:00:00.000Z',
|
||||
updated_at: '2026-02-06T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'sub2api-account',
|
||||
site_url: 'https://sub2api.example.com',
|
||||
site_type: 'sub2api',
|
||||
site_name: 'Sub2API',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 99,
|
||||
username: 'sub2-user',
|
||||
access_token: 'sub2-session-token',
|
||||
},
|
||||
sub2apiAuth: {
|
||||
refreshToken: 'sub2-refresh-token',
|
||||
tokenExpiresAt: 1735689600000,
|
||||
},
|
||||
checkIn: {
|
||||
autoCheckInEnabled: true,
|
||||
},
|
||||
created_at: '2026-02-07T00:00:00.000Z',
|
||||
updated_at: '2026-02-08T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'skipped-none-account',
|
||||
site_url: 'https://skip-none.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Skip None',
|
||||
authType: 'none',
|
||||
username: 'skip-none-user',
|
||||
created_at: '2026-02-09T00:00:00.000Z',
|
||||
updated_at: '2026-02-10T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'skipped-empty-account',
|
||||
site_url: 'https://skip-empty.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Skip Empty',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'skip-empty-user',
|
||||
},
|
||||
created_at: '2026-02-11T00:00:00.000Z',
|
||||
updated_at: '2026-02-12T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
bookmarks: [
|
||||
{
|
||||
id: 'bookmark-1',
|
||||
name: 'Ignored Bookmark',
|
||||
url: 'https://bookmark.example.com',
|
||||
},
|
||||
],
|
||||
pinnedAccountIds: ['direct-openai-account'],
|
||||
orderedAccountIds: ['managed-account', 'cookie-account', 'direct-openai-account'],
|
||||
last_updated: 1735689600000,
|
||||
},
|
||||
preferences: {
|
||||
language: 'zh-CN',
|
||||
},
|
||||
channelConfigs: {
|
||||
bySite: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
tagStore: {
|
||||
version: 1,
|
||||
tagsById: {},
|
||||
},
|
||||
apiCredentialProfiles: {
|
||||
version: 2,
|
||||
profiles: [
|
||||
{
|
||||
id: 'profile-openai',
|
||||
name: 'OpenAI Profile',
|
||||
apiType: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-profile-openai',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689601000,
|
||||
updatedAt: 1735689602000,
|
||||
},
|
||||
{
|
||||
id: 'profile-anthropic',
|
||||
name: 'Claude Profile',
|
||||
apiType: 'anthropic',
|
||||
baseUrl: 'https://api.anthropic.com/v1',
|
||||
apiKey: 'sk-profile-claude',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689603000,
|
||||
updatedAt: 1735689604000,
|
||||
},
|
||||
{
|
||||
id: 'profile-gemini',
|
||||
name: 'Gemini Profile',
|
||||
apiType: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'gemini-profile-key',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689605000,
|
||||
updatedAt: 1735689606000,
|
||||
},
|
||||
{
|
||||
id: 'profile-compat-fallback',
|
||||
name: 'Compat Profile',
|
||||
apiType: 'openai-compatible',
|
||||
baseUrl: 'https://compat.example.com/v1',
|
||||
apiKey: 'sk-compat-profile',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689607000,
|
||||
updatedAt: 1735689608000,
|
||||
},
|
||||
],
|
||||
lastUpdated: 1735689609000,
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const result = await backupService.importBackup(payload);
|
||||
const summary = (result as any).summary;
|
||||
const warnings = (result as any).warnings;
|
||||
|
||||
expect(result.allImported).toBe(true);
|
||||
expect(result.sections.accounts).toBe(true);
|
||||
expect(result.sections.preferences).toBe(true);
|
||||
expect(summary).toMatchObject({
|
||||
importedAccounts: 4,
|
||||
importedProfiles: 4,
|
||||
importedApiKeyConnections: 5,
|
||||
importedSites: 7,
|
||||
skippedAccounts: 2,
|
||||
});
|
||||
expect(summary.ignoredSections).toEqual(
|
||||
expect.arrayContaining(['accounts.bookmarks', 'channelConfigs', 'tagStore']),
|
||||
);
|
||||
expect(warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('skipped-none-account'),
|
||||
expect.stringContaining('skipped-empty-account'),
|
||||
]),
|
||||
);
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
const accountTokens = await db.select().from(schema.accountTokens).all();
|
||||
const settings = await db.select().from(schema.settings).all();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sites).toHaveLength(7);
|
||||
expect(accounts).toHaveLength(8);
|
||||
expect(accountTokens).toHaveLength(5);
|
||||
expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true);
|
||||
expect(settings.some((row) => row.key === 'legacy_channel_configs_ref_v2')).toBe(true);
|
||||
expect(settings.some((row) => row.key === 'legacy_tag_store_ref_v2')).toBe(true);
|
||||
expect(settings.some((row) => row.key === 'legacy_api_credential_profiles_ref_v2')).toBe(false);
|
||||
|
||||
const managedAccount = accounts.find((row) => row.username === 'managed-user');
|
||||
const cookieAccount = accounts.find((row) => row.username === 'cookie-user');
|
||||
const openAiAccount = accounts.find((row) => row.username === 'openai-account');
|
||||
const sub2apiAccount = accounts.find((row) => row.username === 'sub2-user');
|
||||
const openAiProfileAccount = accounts.find((row) => row.username === 'OpenAI Profile');
|
||||
const claudeProfileAccount = accounts.find((row) => row.username === 'Claude Profile');
|
||||
const geminiProfileAccount = accounts.find((row) => row.username === 'Gemini Profile');
|
||||
const compatProfileAccount = accounts.find((row) => row.username === 'Compat Profile');
|
||||
|
||||
expect(managedAccount?.accessToken).toBe('managed-session-token');
|
||||
expect(managedAccount?.apiToken).toBeNull();
|
||||
expect(managedAccount?.checkinEnabled).toBe(true);
|
||||
expect(JSON.parse(managedAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
platformUserId: 7788,
|
||||
});
|
||||
|
||||
expect(cookieAccount?.accessToken).toBe('sid=cookie-session');
|
||||
expect(cookieAccount?.checkinEnabled).toBe(false);
|
||||
expect(JSON.parse(cookieAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
});
|
||||
|
||||
expect(openAiAccount?.accessToken).toBe('');
|
||||
expect(openAiAccount?.apiToken).toBe('sk-openai-account');
|
||||
expect(openAiAccount?.checkinEnabled).toBe(false);
|
||||
expect(JSON.parse(openAiAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'apikey',
|
||||
});
|
||||
|
||||
expect(sub2apiAccount?.accessToken).toBe('sub2-session-token');
|
||||
expect(JSON.parse(sub2apiAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'session',
|
||||
platformUserId: 99,
|
||||
sub2apiAuth: {
|
||||
refreshToken: 'sub2-refresh-token',
|
||||
tokenExpiresAt: 1735689600000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(openAiProfileAccount?.accessToken).toBe('');
|
||||
expect(openAiProfileAccount?.apiToken).toBe('sk-profile-openai');
|
||||
expect(JSON.parse(openAiProfileAccount?.extraConfig || '{}')).toMatchObject({
|
||||
credentialMode: 'apikey',
|
||||
});
|
||||
expect(claudeProfileAccount?.apiToken).toBe('sk-profile-claude');
|
||||
expect(geminiProfileAccount?.apiToken).toBe('gemini-profile-key');
|
||||
expect(compatProfileAccount?.apiToken).toBe('sk-compat-profile');
|
||||
|
||||
const openAiSite = sites.find((row) => row.platform === 'openai' && row.url === 'https://api.openai.com');
|
||||
expect(openAiSite).toBeTruthy();
|
||||
expect(accounts.filter((row) => row.siteId === openAiSite?.id)).toHaveLength(2);
|
||||
|
||||
expect(accountTokens.map((row) => row.token).sort()).toEqual([
|
||||
'gemini-profile-key',
|
||||
'sk-compat-profile',
|
||||
'sk-openai-account',
|
||||
'sk-profile-claude',
|
||||
'sk-profile-openai',
|
||||
]);
|
||||
expect(accountTokens.every((row) => row.name === 'default' && row.isDefault && row.source === 'legacy')).toBe(true);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('backfills oauth columns from extraConfig when importing older backups', async () => {
|
||||
const payload = {
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm';
|
||||
import cron from 'node-cron';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
import { mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
|
||||
|
||||
const BACKUP_VERSION = '2.0';
|
||||
@@ -60,6 +61,15 @@ interface BackupImportResult {
|
||||
preferences: boolean;
|
||||
};
|
||||
appliedSettings: Array<{ key: string; value: unknown }>;
|
||||
summary?: {
|
||||
importedSites: number;
|
||||
importedAccounts: number;
|
||||
importedProfiles: number;
|
||||
importedApiKeyConnections: number;
|
||||
skippedAccounts: number;
|
||||
ignoredSections: string[];
|
||||
};
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
const EXCLUDED_SETTING_KEYS = new Set<string>([
|
||||
@@ -67,6 +77,48 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
|
||||
'auth_token',
|
||||
]);
|
||||
|
||||
const DIRECT_API_PLATFORMS = new Set([
|
||||
'openai',
|
||||
'claude',
|
||||
'gemini',
|
||||
'cliproxyapi',
|
||||
'codex',
|
||||
'gemini-cli',
|
||||
'antigravity',
|
||||
]);
|
||||
|
||||
const IMPORT_PLATFORM_ALIASES: Record<string, string> = {
|
||||
anyrouter: 'anyrouter',
|
||||
'wong-gongyi': 'new-api',
|
||||
'vo-api': 'new-api',
|
||||
'super-api': 'new-api',
|
||||
'rix-api': 'new-api',
|
||||
'neo-api': 'new-api',
|
||||
newapi: 'new-api',
|
||||
'new api': 'new-api',
|
||||
'new-api': 'new-api',
|
||||
oneapi: 'one-api',
|
||||
'one api': 'one-api',
|
||||
'one-api': 'one-api',
|
||||
onehub: 'one-hub',
|
||||
'one-hub': 'one-hub',
|
||||
donehub: 'done-hub',
|
||||
'done-hub': 'done-hub',
|
||||
veloera: 'veloera',
|
||||
sub2api: 'sub2api',
|
||||
openai: 'openai',
|
||||
anthropic: 'claude',
|
||||
claude: 'claude',
|
||||
google: 'gemini',
|
||||
gemini: 'gemini',
|
||||
cliproxyapi: 'cliproxyapi',
|
||||
cpa: 'cliproxyapi',
|
||||
'cli-proxy-api': 'cliproxyapi',
|
||||
codex: 'codex',
|
||||
'gemini-cli': 'gemini-cli',
|
||||
antigravity: 'antigravity',
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -141,6 +193,370 @@ function normalizeLegacyPlatform(raw: string): string {
|
||||
return 'new-api';
|
||||
}
|
||||
|
||||
function normalizeOriginUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
return new URL(trimmed).origin;
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function detectLocalPlatformByUrlHint(url: string): string | undefined {
|
||||
const normalized = url.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
|
||||
if (normalized.includes('api.openai.com')) return 'openai';
|
||||
if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex';
|
||||
if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude';
|
||||
if (
|
||||
normalized.includes('generativelanguage.googleapis.com')
|
||||
|| normalized.includes('googleapis.com/v1beta/openai')
|
||||
|| normalized.includes('gemini.google.com')
|
||||
) {
|
||||
return 'gemini';
|
||||
}
|
||||
if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli';
|
||||
if (normalized.includes('anyrouter')) return 'anyrouter';
|
||||
if (normalized.includes('donehub') || normalized.includes('done-hub')) return 'done-hub';
|
||||
if (normalized.includes('onehub') || normalized.includes('one-hub')) return 'one-hub';
|
||||
if (normalized.includes('veloera')) return 'veloera';
|
||||
if (normalized.includes('sub2api')) return 'sub2api';
|
||||
if (normalized.includes('127.0.0.1:8317') || normalized.includes('localhost:8317')) return 'cliproxyapi';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveImportedPlatform(rawPlatform: unknown, rawUrl: string): string {
|
||||
const normalizedPlatform = typeof rawPlatform === 'string'
|
||||
? IMPORT_PLATFORM_ALIASES[rawPlatform.trim().toLowerCase()]
|
||||
: undefined;
|
||||
if (normalizedPlatform) return normalizedPlatform;
|
||||
|
||||
const urlHint = detectLocalPlatformByUrlHint(rawUrl);
|
||||
if (urlHint) return urlHint;
|
||||
|
||||
return normalizeLegacyPlatform(asString(rawPlatform));
|
||||
}
|
||||
|
||||
function resolveImportedProfilePlatform(apiType: unknown, baseUrl: string): string {
|
||||
const normalizedType = asString(apiType).toLowerCase();
|
||||
if (normalizedType === 'openai') return 'openai';
|
||||
if (normalizedType === 'anthropic') return 'claude';
|
||||
if (normalizedType === 'google') return 'gemini';
|
||||
if (normalizedType === 'openai-compatible') {
|
||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
||||
}
|
||||
return detectLocalPlatformByUrlHint(baseUrl) || 'openai';
|
||||
}
|
||||
|
||||
function pushDefaultImportedToken(
|
||||
rows: AccountTokenRow[],
|
||||
nextId: () => number,
|
||||
accountId: number,
|
||||
token: string | null,
|
||||
createdAt: string,
|
||||
updatedAt: string,
|
||||
) {
|
||||
if (!token) return;
|
||||
rows.push({
|
||||
id: nextId(),
|
||||
accountId,
|
||||
name: 'default',
|
||||
token,
|
||||
tokenGroup: 'default',
|
||||
valueStatus: 'ready',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function buildAllApiHubV2AccountsSection(data: RawBackupData): {
|
||||
section: AccountsBackupSection;
|
||||
summary: NonNullable<BackupImportResult['summary']>;
|
||||
warnings: string[];
|
||||
} | null {
|
||||
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
||||
if (!accountsContainer || !Array.isArray(accountsContainer.accounts)) return null;
|
||||
|
||||
if (coerceAccountsSection(accountsContainer)) return null;
|
||||
|
||||
const looksLikeLegacyAccountRow = accountsContainer.accounts.some((row) => (
|
||||
isRecord(row) && (
|
||||
Object.prototype.hasOwnProperty.call(row, 'site_url')
|
||||
|| Object.prototype.hasOwnProperty.call(row, 'site_type')
|
||||
|| Object.prototype.hasOwnProperty.call(row, 'account_info')
|
||||
|| Object.prototype.hasOwnProperty.call(row, 'cookieAuth')
|
||||
|| Object.prototype.hasOwnProperty.call(row, 'authType')
|
||||
|| Object.prototype.hasOwnProperty.call(row, 'sub2apiAuth')
|
||||
)
|
||||
));
|
||||
|
||||
const looksLikeV2 =
|
||||
looksLikeLegacyAccountRow
|
||||
&& (
|
||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||
|| Object.prototype.hasOwnProperty.call(accountsContainer, 'last_updated')
|
||||
|| Array.isArray(accountsContainer.bookmarks)
|
||||
|| Array.isArray(accountsContainer.pinnedAccountIds)
|
||||
|| Array.isArray(accountsContainer.orderedAccountIds)
|
||||
|| (isRecord(data.apiCredentialProfiles) && Array.isArray(data.apiCredentialProfiles.profiles))
|
||||
);
|
||||
|
||||
if (!looksLikeV2) return null;
|
||||
|
||||
const section: AccountsBackupSection = {
|
||||
sites: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
routeGroupSources: [],
|
||||
};
|
||||
const siteIdByKey = new Map<string, number>();
|
||||
let nextSiteId = 1;
|
||||
let nextAccountId = 1;
|
||||
let nextTokenId = 1;
|
||||
const warnings: string[] = [];
|
||||
const ignoredSections: string[] = [];
|
||||
let importedAccounts = 0;
|
||||
let importedProfiles = 0;
|
||||
let importedApiKeyConnections = 0;
|
||||
let skippedAccounts = 0;
|
||||
|
||||
const nextToken = () => nextTokenId++;
|
||||
const ensureSite = (input: {
|
||||
platform: string;
|
||||
url: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => {
|
||||
const normalizedUrl = normalizeOriginUrl(input.url);
|
||||
if (!normalizedUrl) return null;
|
||||
const key = `${input.platform}::${normalizedUrl}`;
|
||||
const existingId = siteIdByKey.get(key);
|
||||
if (existingId) return existingId;
|
||||
|
||||
const siteId = nextSiteId++;
|
||||
siteIdByKey.set(key, siteId);
|
||||
section.sites.push({
|
||||
id: siteId,
|
||||
name: asString(input.name) || normalizedUrl,
|
||||
url: normalizedUrl,
|
||||
externalCheckinUrl: null,
|
||||
platform: input.platform,
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
customHeaders: null,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: section.sites.length,
|
||||
globalWeight: 1,
|
||||
apiKey: null,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
return siteId;
|
||||
};
|
||||
|
||||
const addIgnoredSection = (name: string, active: boolean) => {
|
||||
if (active && !ignoredSections.includes(name)) ignoredSections.push(name);
|
||||
};
|
||||
|
||||
addIgnoredSection('accounts.bookmarks', Array.isArray(accountsContainer.bookmarks) && accountsContainer.bookmarks.length > 0);
|
||||
addIgnoredSection('channelConfigs', isRecord(data.channelConfigs));
|
||||
addIgnoredSection('tagStore', isRecord(data.tagStore));
|
||||
|
||||
for (const row of accountsContainer.accounts) {
|
||||
if (!isRecord(row)) continue;
|
||||
|
||||
const createdAt = toIsoString(row.created_at);
|
||||
const updatedAt = toIsoString(row.updated_at);
|
||||
const siteUrl = normalizeOriginUrl(asString(row.site_url));
|
||||
const siteName = asString(row.site_name) || siteUrl;
|
||||
const platform = resolveImportedPlatform(row.site_type, siteUrl);
|
||||
const authType = asString(row.authType).toLowerCase();
|
||||
const accountInfo = isRecord(row.account_info) ? row.account_info : {};
|
||||
const cookieAuth = isRecord(row.cookieAuth) ? row.cookieAuth : {};
|
||||
const sub2apiAuth = isRecord(row.sub2apiAuth) ? row.sub2apiAuth : {};
|
||||
const rawAccountId = asString(row.id) || asString(row.username) || siteName || `account-${nextAccountId}`;
|
||||
const username = asString(accountInfo.username) || asString(row.username) || rawAccountId;
|
||||
const platformUserId = asNumber(accountInfo.id, 0);
|
||||
const checkin = isRecord(row.checkIn) ? row.checkIn : {};
|
||||
const accessTokenCandidate = asString(accountInfo.access_token) || asString(row.access_token);
|
||||
const cookieSession = asString(cookieAuth.sessionCookie);
|
||||
const isDirectApiPlatform = DIRECT_API_PLATFORMS.has(platform);
|
||||
|
||||
let accessToken = '';
|
||||
let apiToken: string | null = null;
|
||||
let credentialMode: 'session' | 'apikey' | null = null;
|
||||
|
||||
if (authType === 'cookie') {
|
||||
if (!cookieSession) {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:cookieAuth.sessionCookie 缺失`);
|
||||
continue;
|
||||
}
|
||||
accessToken = cookieSession;
|
||||
credentialMode = 'session';
|
||||
} else if (authType === 'access_token') {
|
||||
if (!accessTokenCandidate) {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:access_token 缺失`);
|
||||
continue;
|
||||
}
|
||||
if (isDirectApiPlatform) {
|
||||
accessToken = '';
|
||||
apiToken = accessTokenCandidate;
|
||||
credentialMode = 'apikey';
|
||||
} else {
|
||||
accessToken = accessTokenCandidate;
|
||||
credentialMode = 'session';
|
||||
}
|
||||
} else {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:authType=${authType || 'unknown'} 不支持离线迁移`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const siteId = ensureSite({
|
||||
platform,
|
||||
url: siteUrl,
|
||||
name: siteName,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
if (!siteId) {
|
||||
skippedAccounts += 1;
|
||||
warnings.push(`跳过 ALL-API-Hub 账号 ${rawAccountId}:site_url 无效`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const importedBalance = normalizeLegacyQuota(accountInfo.quota);
|
||||
const importedUsed = normalizeLegacyQuota(accountInfo.today_quota_consumption);
|
||||
const importedQuota = importedBalance + importedUsed;
|
||||
const extraConfigPatch: Record<string, unknown> = {
|
||||
credentialMode,
|
||||
source: 'all-api-hub',
|
||||
};
|
||||
if (platformUserId > 0) {
|
||||
extraConfigPatch.platformUserId = platformUserId;
|
||||
}
|
||||
const refreshToken = asString(sub2apiAuth.refreshToken);
|
||||
const tokenExpiresAt = asNumber(sub2apiAuth.tokenExpiresAt, 0);
|
||||
if (refreshToken) {
|
||||
extraConfigPatch.sub2apiAuth = tokenExpiresAt > 0
|
||||
? { refreshToken, tokenExpiresAt }
|
||||
: { refreshToken };
|
||||
}
|
||||
|
||||
const accountId = nextAccountId++;
|
||||
section.accounts.push({
|
||||
id: accountId,
|
||||
siteId,
|
||||
username,
|
||||
accessToken,
|
||||
apiToken,
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
balance: importedBalance,
|
||||
balanceUsed: importedUsed,
|
||||
quota: importedQuota > 0 ? importedQuota : importedBalance,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: asBoolean(row.disabled, false) ? 'disabled' : 'active',
|
||||
isPinned: false,
|
||||
sortOrder: section.accounts.length,
|
||||
checkinEnabled: credentialMode === 'session' ? asBoolean(checkin.autoCheckInEnabled, true) : false,
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: mergeAccountExtraConfig(undefined, extraConfigPatch),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiToken, createdAt, updatedAt);
|
||||
if (credentialMode === 'apikey') importedApiKeyConnections += 1;
|
||||
importedAccounts += 1;
|
||||
}
|
||||
|
||||
const profilesContainer = isRecord(data.apiCredentialProfiles) ? data.apiCredentialProfiles : null;
|
||||
const profiles = Array.isArray(profilesContainer?.profiles) ? profilesContainer.profiles : [];
|
||||
for (const profile of profiles) {
|
||||
if (!isRecord(profile)) continue;
|
||||
|
||||
const baseUrl = normalizeOriginUrl(asString(profile.baseUrl));
|
||||
const apiKey = asString(profile.apiKey);
|
||||
if (!baseUrl || !apiKey) {
|
||||
warnings.push(`跳过 ALL-API-Hub API 凭据 ${asString(profile.id) || asString(profile.name) || 'unknown'}:baseUrl 或 apiKey 缺失`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdAt = toIsoString(profile.createdAt);
|
||||
const updatedAt = toIsoString(profile.updatedAt);
|
||||
const platform = resolveImportedProfilePlatform(profile.apiType, asString(profile.baseUrl));
|
||||
const siteId = ensureSite({
|
||||
platform,
|
||||
url: baseUrl,
|
||||
name: baseUrl,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
if (!siteId) continue;
|
||||
|
||||
const accountId = nextAccountId++;
|
||||
section.accounts.push({
|
||||
id: accountId,
|
||||
siteId,
|
||||
username: asString(profile.name) || asString(profile.id) || baseUrl,
|
||||
accessToken: '',
|
||||
apiToken: apiKey,
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
quota: 0,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: section.accounts.length,
|
||||
checkinEnabled: false,
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: mergeAccountExtraConfig(undefined, {
|
||||
credentialMode: 'apikey',
|
||||
source: 'all-api-hub-profile',
|
||||
importedProfileId: asString(profile.id) || undefined,
|
||||
}),
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
pushDefaultImportedToken(section.accountTokens, nextToken, accountId, apiKey, createdAt, updatedAt);
|
||||
importedApiKeyConnections += 1;
|
||||
importedProfiles += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
section,
|
||||
summary: {
|
||||
importedSites: section.sites.length,
|
||||
importedAccounts,
|
||||
importedProfiles,
|
||||
importedApiKeyConnections,
|
||||
skippedAccounts,
|
||||
ignoredSections,
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupSection | null {
|
||||
const accountsContainer = isRecord(data.accounts) ? data.accounts : null;
|
||||
const rows = Array.isArray(accountsContainer?.accounts) ? accountsContainer.accounts : null;
|
||||
@@ -285,9 +701,6 @@ function buildPreferencesSectionFromRefBackup(data: RawBackupData): PreferencesB
|
||||
if (isRecord(data.channelConfigs)) {
|
||||
settings.push({ key: 'legacy_channel_configs_ref_v2', value: data.channelConfigs });
|
||||
}
|
||||
if (isRecord(data.apiCredentialProfiles)) {
|
||||
settings.push({ key: 'legacy_api_credential_profiles_ref_v2', value: data.apiCredentialProfiles });
|
||||
}
|
||||
if (isRecord(data.tagStore)) {
|
||||
settings.push({ key: 'legacy_tag_store_ref_v2', value: data.tagStore });
|
||||
}
|
||||
@@ -457,6 +870,9 @@ function detectAccountsSection(data: RawBackupData): AccountsBackupSection | nul
|
||||
if (legacyNested) return legacyNested;
|
||||
}
|
||||
|
||||
const allApiHubV2 = buildAllApiHubV2AccountsSection(data);
|
||||
if (allApiHubV2) return allApiHubV2.section;
|
||||
|
||||
const refFormat = buildAccountsSectionFromRefBackup(data);
|
||||
if (refFormat) return refFormat;
|
||||
|
||||
@@ -483,6 +899,18 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectImportMetadata(data: RawBackupData): {
|
||||
summary?: BackupImportResult['summary'];
|
||||
warnings?: string[];
|
||||
} {
|
||||
const allApiHubV2 = buildAllApiHubV2AccountsSection(data);
|
||||
if (!allApiHubV2) return {};
|
||||
return {
|
||||
summary: allApiHubV2.summary,
|
||||
warnings: allApiHubV2.warnings.length > 0 ? allApiHubV2.warnings : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function importAccountsSection(section: AccountsBackupSection): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.routeChannels).run();
|
||||
@@ -634,6 +1062,7 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
|
||||
|
||||
const accountsSection = detectAccountsSection(data);
|
||||
const preferencesSection = detectPreferencesSection(data);
|
||||
const importMetadata = detectImportMetadata(data);
|
||||
|
||||
const type = typeof data.type === 'string' ? data.type : '';
|
||||
const accountsRequested = type === 'accounts' || !!accountsSection;
|
||||
@@ -670,5 +1099,7 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
|
||||
preferences: preferencesImported,
|
||||
},
|
||||
appliedSettings,
|
||||
summary: importMetadata.summary,
|
||||
warnings: importMetadata.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type StoredUtcDateTimeInput = string | number | Date | null | undefined;
|
||||
|
||||
function pad2(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
@@ -20,11 +22,38 @@ export function formatUtcSqlDateTime(value: Date): string {
|
||||
return `${value.getUTCFullYear()}-${pad2(value.getUTCMonth() + 1)}-${pad2(value.getUTCDate())} ${pad2(value.getUTCHours())}:${pad2(value.getUTCMinutes())}:${pad2(value.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
export function parseStoredUtcDateTime(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
function parseEpochDateTime(raw: number): Date | null {
|
||||
if (!Number.isFinite(raw)) return null;
|
||||
if (raw > 1_000_000_000_000) {
|
||||
const parsed = new Date(Math.round(raw));
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
if (raw > 1_000_000_000) {
|
||||
const parsed = new Date(Math.round(raw * 1000));
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseStoredUtcDateTime(raw: StoredUtcDateTimeInput): Date | null {
|
||||
if (raw == null) return null;
|
||||
if (raw instanceof Date) {
|
||||
return Number.isNaN(raw.getTime()) ? null : new Date(raw.getTime());
|
||||
}
|
||||
if (typeof raw === 'number') {
|
||||
return parseEpochDateTime(raw);
|
||||
}
|
||||
if (typeof raw !== 'string') return null;
|
||||
|
||||
const text = raw.trim();
|
||||
if (!text) return null;
|
||||
|
||||
const numeric = Number(text);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const parsedNumeric = parseEpochDateTime(numeric);
|
||||
if (parsedNumeric) return parsedNumeric;
|
||||
}
|
||||
|
||||
let parsed: Date;
|
||||
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) {
|
||||
parsed = new Date(`${text.replace(' ', 'T')}Z`);
|
||||
@@ -36,7 +65,7 @@ export function parseStoredUtcDateTime(raw: string | null | undefined): Date | n
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function toLocalDayKeyFromStoredUtc(raw: string | null | undefined): string | null {
|
||||
export function toLocalDayKeyFromStoredUtc(raw: StoredUtcDateTimeInput): string | null {
|
||||
const parsed = parseStoredUtcDateTime(raw);
|
||||
if (!parsed) return null;
|
||||
return formatLocalDate(parsed);
|
||||
|
||||
@@ -148,4 +148,35 @@ describe('buildModelAnalysis', () => {
|
||||
calls: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts Date-backed createdAt values from external database drivers', () => {
|
||||
const logs = [
|
||||
{
|
||||
createdAt: new Date('2026-02-24T12:30:00.000Z') as unknown as string,
|
||||
modelActual: 'gpt-4o',
|
||||
modelRequested: null,
|
||||
status: 'success',
|
||||
latencyMs: 250,
|
||||
totalTokens: 800,
|
||||
estimatedCost: 1.6,
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildModelAnalysis(logs, {
|
||||
now: new Date('2026-02-24T12:00:00.000Z'),
|
||||
days: 1,
|
||||
});
|
||||
|
||||
expect(result.totals).toEqual({
|
||||
calls: 1,
|
||||
tokens: 800,
|
||||
spend: 1.6,
|
||||
});
|
||||
expect(result.callRanking[0]).toMatchObject({
|
||||
model: 'gpt-4o',
|
||||
calls: 1,
|
||||
successRate: 100,
|
||||
avgLatencyMs: 250,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { formatLocalDate, parseStoredUtcDateTime } from './localTimeService.js';
|
||||
import { formatLocalDate, parseStoredUtcDateTime, type StoredUtcDateTimeInput } from './localTimeService.js';
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface ProxyLogLike {
|
||||
createdAt: string | null;
|
||||
createdAt: StoredUtcDateTimeInput;
|
||||
modelActual: string | null;
|
||||
modelRequested: string | null;
|
||||
status: string | null;
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let refreshModelsForAccount: ModelServiceModule['refreshModelsForAccount'];
|
||||
let refreshModelsAndRebuildRoutes: ModelServiceModule['refreshModelsAndRebuildRoutes'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -39,6 +40,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
refreshModelsForAccount = modelService.refreshModelsForAccount;
|
||||
refreshModelsAndRebuildRoutes = modelService.refreshModelsAndRebuildRoutes;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -142,6 +144,68 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']);
|
||||
});
|
||||
|
||||
it('reuses one in-flight full refresh when concurrent callers request a rebuild', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
let releaseGate: (() => void) | null = null;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
|
||||
getModelsMock.mockImplementation(async () => {
|
||||
await gate;
|
||||
return ['gpt-5-nano'];
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-concurrent-refresh',
|
||||
url: 'https://site-concurrent-refresh.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'concurrent-refresh-user',
|
||||
accessToken: 'shared-credential',
|
||||
apiToken: 'shared-credential',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'shared-credential',
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).run();
|
||||
|
||||
const firstRefresh = refreshModelsAndRebuildRoutes();
|
||||
const secondRefresh = refreshModelsAndRebuildRoutes();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
releaseGate?.();
|
||||
|
||||
const results = await Promise.allSettled([firstRefresh, secondRefresh]);
|
||||
expect(results.every((item) => item.status === 'fulfilled')).toBe(true);
|
||||
expect(getModelsMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const modelRows = await db.select().from(schema.modelAvailability)
|
||||
.where(eq(schema.modelAvailability.accountId, account.id))
|
||||
.all();
|
||||
expect(modelRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']);
|
||||
|
||||
const token = await db.select().from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.get();
|
||||
const tokenRows = await db.select().from(schema.tokenModelAvailability)
|
||||
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
|
||||
.all();
|
||||
expect(tokenRows.map((row) => row.modelName)).toEqual(['gpt-5-nano']);
|
||||
});
|
||||
|
||||
it('marks runtime health unhealthy when model discovery fails', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token'));
|
||||
|
||||
@@ -60,6 +60,10 @@ const GEMINI_CLI_STATIC_MODELS = [
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
];
|
||||
let inFlightRefreshModelsAndRebuildRoutes: Promise<{
|
||||
refresh: ModelRefreshResult[];
|
||||
rebuild: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>>;
|
||||
}> | null = null;
|
||||
|
||||
type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown';
|
||||
type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status';
|
||||
@@ -1181,8 +1185,24 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshModelsAndRebuildRoutes() {
|
||||
async function runRefreshModelsAndRebuildRoutes() {
|
||||
const refresh = await refreshModelsForAllActiveAccounts();
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
return { refresh, rebuild };
|
||||
}
|
||||
|
||||
export async function refreshModelsAndRebuildRoutes() {
|
||||
if (inFlightRefreshModelsAndRebuildRoutes) {
|
||||
return inFlightRefreshModelsAndRebuildRoutes;
|
||||
}
|
||||
|
||||
inFlightRefreshModelsAndRebuildRoutes = (async () => {
|
||||
try {
|
||||
return await runRefreshModelsAndRebuildRoutes();
|
||||
} finally {
|
||||
inFlightRefreshModelsAndRebuildRoutes = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlightRefreshModelsAndRebuildRoutes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { anthropicMessagesTransformer } from './index.js';
|
||||
|
||||
describe('anthropicMessagesTransformer protocol contract', () => {
|
||||
it('parses native messages requests into canonical envelopes', () => {
|
||||
const result = anthropicMessagesTransformer.parseRequest({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
user_id: 'user_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef_account__session_11111111-2222-3333-4444-555555555555',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds native messages requests from canonical envelopes', () => {
|
||||
const body = anthropicMessagesTransformer.buildProtocolRequest({
|
||||
operation: 'count_tokens',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'count these tokens' }],
|
||||
},
|
||||
],
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'count these tokens',
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type ParsedDownstreamChatRequest, type StreamTransformContext, type ClaudeDownstreamContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { anthropicMessagesInbound } from './inbound.js';
|
||||
import { convertOpenAiBodyToAnthropicMessagesBody } from './conversion.js';
|
||||
import { anthropicMessagesOutbound } from './outbound.js';
|
||||
import { anthropicMessagesStream, consumeAnthropicSseEvent } from './stream.js';
|
||||
import { anthropicMessagesUsage } from './usage.js';
|
||||
import { createAnthropicMessagesAggregateState } from './aggregator.js';
|
||||
import { isMessagesRequiredError, shouldRetryNormalizedMessagesBody } from './compatibility.js';
|
||||
import {
|
||||
isMessagesRequiredError,
|
||||
shouldRetryNormalizedMessagesBody,
|
||||
} from './compatibility.js';
|
||||
export {
|
||||
ANTHROPIC_RAW_SSE_EVENT_NAMES,
|
||||
consumeAnthropicSseEvent,
|
||||
@@ -30,6 +37,47 @@ export const anthropicMessagesTransformer = {
|
||||
aggregator: {
|
||||
createState: createAnthropicMessagesAggregateState,
|
||||
},
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = anthropicMessagesInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid messages request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
return convertOpenAiBodyToAnthropicMessagesBody(openAiBody, request.requestedModel, request.stream);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof anthropicMessagesInbound.parse> {
|
||||
return anthropicMessagesInbound.parse(body);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canonicalAttachmentFromInputFileBlock,
|
||||
canonicalAttachmentToNormalizedInputFile,
|
||||
} from './attachments.js';
|
||||
|
||||
describe('canonical attachment helpers', () => {
|
||||
it('normalizes input_file blocks and prefers file_data over file_url and file_id', () => {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock({
|
||||
type: 'input_file',
|
||||
file_data: 'data:application/pdf;base64,QUJD',
|
||||
file_url: 'https://example.com/brief.pdf',
|
||||
file_id: 'file_ignored',
|
||||
filename: 'brief.pdf',
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
kind: 'file',
|
||||
sourceType: 'input_file',
|
||||
fileData: 'data:application/pdf;base64,QUJD',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(canonicalAttachmentToNormalizedInputFile(attachment!)).toEqual({
|
||||
sourceType: 'input_file',
|
||||
fileData: 'data:application/pdf;base64,QUJD',
|
||||
filename: 'brief.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
hadDataUrl: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves file_url when no file_data exists', () => {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock({
|
||||
type: 'file',
|
||||
file: {
|
||||
file_url: 'https://example.com/report.json',
|
||||
filename: 'report.json',
|
||||
mime_type: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
kind: 'file',
|
||||
sourceType: 'file',
|
||||
fileUrl: 'https://example.com/report.json',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
normalizeInputFileBlock,
|
||||
type NormalizedInputFile,
|
||||
} from '../shared/inputFile.js';
|
||||
|
||||
export type CanonicalAttachment = {
|
||||
kind: 'file';
|
||||
sourceType?: 'file' | 'input_file';
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
fileData?: string;
|
||||
filename?: string;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
function cloneOptionalString(value: string | null | undefined): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function canonicalAttachmentFromNormalizedInputFile(
|
||||
file: NormalizedInputFile,
|
||||
): CanonicalAttachment {
|
||||
return {
|
||||
kind: 'file',
|
||||
sourceType: file.sourceType,
|
||||
...(cloneOptionalString(file.fileId) ? { fileId: file.fileId } : {}),
|
||||
...(cloneOptionalString(file.fileUrl) ? { fileUrl: file.fileUrl } : {}),
|
||||
...(cloneOptionalString(file.fileData) ? { fileData: file.fileData } : {}),
|
||||
...(cloneOptionalString(file.filename) ? { filename: file.filename } : {}),
|
||||
...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalAttachmentFromInputFileBlock(
|
||||
item: Record<string, unknown>,
|
||||
): CanonicalAttachment | null {
|
||||
const normalized = normalizeInputFileBlock(item);
|
||||
if (!normalized) return null;
|
||||
return canonicalAttachmentFromNormalizedInputFile(normalized);
|
||||
}
|
||||
|
||||
export function canonicalAttachmentToNormalizedInputFile(
|
||||
attachment: CanonicalAttachment,
|
||||
): NormalizedInputFile {
|
||||
const hadDataUrl = typeof attachment.fileData === 'string'
|
||||
? /^data:[^;,]+;base64,/i.test(attachment.fileData)
|
||||
: undefined;
|
||||
return {
|
||||
...(attachment.sourceType ? { sourceType: attachment.sourceType } : {}),
|
||||
...(cloneOptionalString(attachment.fileId) ? { fileId: attachment.fileId } : {}),
|
||||
...(cloneOptionalString(attachment.fileUrl) ? { fileUrl: attachment.fileUrl } : {}),
|
||||
...(cloneOptionalString(attachment.fileData) ? { fileData: attachment.fileData } : {}),
|
||||
...(cloneOptionalString(attachment.filename) ? { filename: attachment.filename } : {}),
|
||||
...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}),
|
||||
...(hadDataUrl !== undefined ? { hadDataUrl } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeCanonicalReasoningRequest } from './reasoning.js';
|
||||
|
||||
describe('canonical reasoning helpers', () => {
|
||||
it('normalizes reasoning config and keeps non-reasoning include entries in metadata', () => {
|
||||
const normalized = normalizeCanonicalReasoningRequest({
|
||||
include: [' reasoning.encrypted_content ', '', 'message.input_image.image_url'],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
reasoning_budget: '2048',
|
||||
reasoning_summary: 'detailed',
|
||||
});
|
||||
|
||||
expect(normalized.reasoning).toEqual({
|
||||
effort: 'high',
|
||||
budgetTokens: 2048,
|
||||
summary: 'detailed',
|
||||
includeEncryptedContent: true,
|
||||
});
|
||||
expect(normalized.metadata).toEqual({
|
||||
include: ['message.input_image.image_url'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when no reasoning hints are present', () => {
|
||||
expect(normalizeCanonicalReasoningRequest({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { TransformerMetadata } from '../shared/normalized.js';
|
||||
import type {
|
||||
CanonicalReasoningEffort,
|
||||
CanonicalReasoningRequest,
|
||||
} from './types.js';
|
||||
|
||||
type CanonicalReasoningNormalizationInput = {
|
||||
include?: unknown;
|
||||
reasoning?: unknown;
|
||||
reasoning_effort?: unknown;
|
||||
reasoning_budget?: unknown;
|
||||
reasoning_summary?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toFiniteInteger(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
if (Number.isFinite(parsed)) return Math.trunc(parsed);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value: unknown): CanonicalReasoningEffort | undefined {
|
||||
const effort = asTrimmedString(value).toLowerCase();
|
||||
switch (effort) {
|
||||
case 'none':
|
||||
case 'low':
|
||||
case 'medium':
|
||||
case 'high':
|
||||
case 'max':
|
||||
return effort;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIncludeEntries(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeCanonicalReasoningRequest(
|
||||
input: CanonicalReasoningNormalizationInput,
|
||||
): {
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
metadata?: Pick<TransformerMetadata, 'include'>;
|
||||
} {
|
||||
const rawReasoning = isRecord(input.reasoning) ? input.reasoning : {};
|
||||
const include = normalizeIncludeEntries(input.include);
|
||||
const filteredInclude = include.filter((item) => item !== 'reasoning.encrypted_content');
|
||||
|
||||
const effort = normalizeReasoningEffort(rawReasoning.effort ?? input.reasoning_effort);
|
||||
const budgetTokens = toFiniteInteger(rawReasoning.budget_tokens ?? rawReasoning.budgetTokens ?? input.reasoning_budget);
|
||||
const summary = asTrimmedString(rawReasoning.summary ?? input.reasoning_summary) || undefined;
|
||||
const includeEncryptedContent = include.includes('reasoning.encrypted_content') || undefined;
|
||||
|
||||
const reasoning: CanonicalReasoningRequest | undefined = (
|
||||
effort
|
||||
|| budgetTokens !== undefined
|
||||
|| summary
|
||||
|| includeEncryptedContent
|
||||
)
|
||||
? {
|
||||
...(effort ? { effort } : {}),
|
||||
...(budgetTokens !== undefined ? { budgetTokens } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(includeEncryptedContent ? { includeEncryptedContent } : {}),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const metadata = filteredInclude.length > 0
|
||||
? { include: filteredInclude }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createCanonicalRequestEnvelope } from './request.js';
|
||||
|
||||
describe('canonical request helpers', () => {
|
||||
it('normalizes a count_tokens request without provider-owned fields', () => {
|
||||
const request = createCanonicalRequestEnvelope({
|
||||
operation: 'count_tokens',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: ' claude-sonnet-4-5 ',
|
||||
stream: false,
|
||||
continuation: {
|
||||
sessionId: ' session-1 ',
|
||||
promptCacheKey: ' cache-1 ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
operation: 'count_tokens',
|
||||
surface: 'anthropic-messages',
|
||||
cliProfile: 'claude_code',
|
||||
requestedModel: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
messages: [],
|
||||
continuation: {
|
||||
sessionId: 'session-1',
|
||||
promptCacheKey: 'cache-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults generate requests to generic profile and empty collections', () => {
|
||||
const request = createCanonicalRequestEnvelope({
|
||||
requestedModel: 'gpt-5.2-codex',
|
||||
surface: 'openai-responses',
|
||||
});
|
||||
|
||||
expect(request).toEqual({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gpt-5.2-codex',
|
||||
stream: false,
|
||||
messages: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
import {
|
||||
canonicalAttachmentFromInputFileBlock,
|
||||
canonicalAttachmentToNormalizedInputFile,
|
||||
type CanonicalAttachment,
|
||||
} from './attachments.js';
|
||||
import { normalizeCanonicalReasoningRequest } from './reasoning.js';
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
import type {
|
||||
CanonicalContentPart,
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalMessage,
|
||||
CanonicalMessageRole,
|
||||
CanonicalOperation,
|
||||
CanonicalReasoningRequest,
|
||||
CanonicalRequestEnvelope,
|
||||
CanonicalSurface,
|
||||
} from './types.js';
|
||||
import { toOpenAiChatFileBlock } from '../shared/inputFile.js';
|
||||
|
||||
export type CreateCanonicalRequestEnvelopeInput = {
|
||||
operation?: CanonicalOperation;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
requestedModel: string;
|
||||
stream?: boolean;
|
||||
messages?: CanonicalMessage[];
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
tools?: CanonicalTool[];
|
||||
toolChoice?: CanonicalToolChoice;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
attachments?: CanonicalAttachment[];
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCanonicalContinuation(
|
||||
continuation: CanonicalContinuation | undefined,
|
||||
): CanonicalContinuation | undefined {
|
||||
if (!continuation) return undefined;
|
||||
|
||||
const normalized: CanonicalContinuation = {
|
||||
...(asTrimmedString(continuation.sessionId) ? { sessionId: asTrimmedString(continuation.sessionId) } : {}),
|
||||
...(asTrimmedString(continuation.previousResponseId)
|
||||
? { previousResponseId: asTrimmedString(continuation.previousResponseId) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.promptCacheKey)
|
||||
? { promptCacheKey: asTrimmedString(continuation.promptCacheKey) }
|
||||
: {}),
|
||||
...(asTrimmedString(continuation.turnState) ? { turnState: asTrimmedString(continuation.turnState) } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function createCanonicalRequestEnvelope(
|
||||
input: CreateCanonicalRequestEnvelopeInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const requestedModel = asTrimmedString(input.requestedModel);
|
||||
if (!requestedModel) {
|
||||
throw new Error('canonical request requires requestedModel');
|
||||
}
|
||||
|
||||
return {
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel,
|
||||
stream: input.stream === true,
|
||||
messages: Array.isArray(input.messages) ? input.messages : [],
|
||||
...(input.reasoning ? { reasoning: input.reasoning } : {}),
|
||||
...(Array.isArray(input.tools) && input.tools.length > 0 ? { tools: input.tools } : {}),
|
||||
...(input.toolChoice !== undefined ? { toolChoice: input.toolChoice } : {}),
|
||||
...(normalizeCanonicalContinuation(input.continuation)
|
||||
? { continuation: normalizeCanonicalContinuation(input.continuation) }
|
||||
: {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...(input.passthrough ? { passthrough: input.passthrough } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type CanonicalRequestFromOpenAiBodyInput = {
|
||||
body: Record<string, unknown>;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
continuation?: CanonicalContinuation;
|
||||
};
|
||||
|
||||
function normalizeRole(value: unknown): CanonicalMessageRole {
|
||||
const role = asTrimmedString(value).toLowerCase();
|
||||
switch (role) {
|
||||
case 'system':
|
||||
case 'developer':
|
||||
case 'assistant':
|
||||
case 'tool':
|
||||
return role;
|
||||
default:
|
||||
return 'user';
|
||||
}
|
||||
}
|
||||
|
||||
function openAiContentToCanonicalParts(content: unknown): CanonicalContentPart[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return [];
|
||||
|
||||
const parts: CanonicalContentPart[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item) parts.push({ type: 'text', text: item });
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(item)) continue;
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
const text = asTrimmedString(item.text);
|
||||
if (text) parts.push({ type: 'text', text });
|
||||
continue;
|
||||
}
|
||||
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
||||
const text = asTrimmedString(item.text ?? item.reasoning ?? item.thinking);
|
||||
if (text) parts.push({ type: 'text', text, thought: true });
|
||||
continue;
|
||||
}
|
||||
if (type === 'image_url' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_image' && isRecord(item.image_url)) {
|
||||
const url = asTrimmedString(item.image_url.url);
|
||||
if (url) parts.push({ type: 'image', url });
|
||||
continue;
|
||||
}
|
||||
if (type === 'input_file' || type === 'file') {
|
||||
const attachment = canonicalAttachmentFromInputFileBlock(item);
|
||||
if (attachment) {
|
||||
parts.push({
|
||||
type: 'file',
|
||||
...(attachment.fileId ? { fileId: attachment.fileId } : {}),
|
||||
...(attachment.fileUrl ? { fileUrl: attachment.fileUrl } : {}),
|
||||
...(attachment.fileData ? { fileData: attachment.fileData } : {}),
|
||||
...(attachment.mimeType !== undefined ? { mimeType: attachment.mimeType } : {}),
|
||||
...(attachment.filename ? { filename: attachment.filename } : {}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefined {
|
||||
if (typeof rawToolChoice === 'string') {
|
||||
const normalized = rawToolChoice.trim().toLowerCase();
|
||||
if (normalized === 'auto' || normalized === 'none' || normalized === 'required') return normalized;
|
||||
if (normalized === 'any') return 'required';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!isRecord(rawToolChoice)) return undefined;
|
||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||
if (type === 'auto' || type === 'none') return type;
|
||||
if (type === 'any' || type === 'required') return 'required';
|
||||
if (type !== 'tool') return undefined;
|
||||
|
||||
const name = asTrimmedString(
|
||||
rawToolChoice.name
|
||||
?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined),
|
||||
);
|
||||
return name ? { type: 'tool', name } : undefined;
|
||||
}
|
||||
|
||||
function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
||||
if (!Array.isArray(rawTools)) return undefined;
|
||||
|
||||
const tools = rawTools
|
||||
.flatMap((item) => {
|
||||
if (!isRecord(item)) return [];
|
||||
|
||||
if (asTrimmedString(item.type).toLowerCase() === 'function' && isRecord(item.function)) {
|
||||
const name = asTrimmedString(item.function.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(item.function.description)
|
||||
? { description: asTrimmedString(item.function.description) }
|
||||
: {}),
|
||||
...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}),
|
||||
}];
|
||||
}
|
||||
|
||||
if (Array.isArray(item.functionDeclarations)) {
|
||||
return item.functionDeclarations.flatMap((declaration) => {
|
||||
if (!isRecord(declaration)) return [];
|
||||
const name = asTrimmedString(declaration.name);
|
||||
if (!name) return [];
|
||||
return [{
|
||||
name,
|
||||
...(asTrimmedString(declaration.description)
|
||||
? { description: asTrimmedString(declaration.description) }
|
||||
: {}),
|
||||
...(isRecord(declaration.parametersJsonSchema)
|
||||
? { inputSchema: cloneJsonValue(declaration.parametersJsonSchema) }
|
||||
: (isRecord(declaration.parameters) ? { inputSchema: cloneJsonValue(declaration.parameters) } : {})),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
export function canonicalRequestFromOpenAiBody(
|
||||
input: CanonicalRequestFromOpenAiBodyInput,
|
||||
): CanonicalRequestEnvelope {
|
||||
const body = input.body;
|
||||
const messages: CanonicalMessage[] = [];
|
||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||
|
||||
for (const rawMessage of rawMessages) {
|
||||
if (!isRecord(rawMessage)) continue;
|
||||
const role = normalizeRole(rawMessage.role);
|
||||
|
||||
if (role === 'tool') {
|
||||
const toolCallId = asTrimmedString(rawMessage.tool_call_id ?? rawMessage.id);
|
||||
const resultText = typeof rawMessage.content === 'string'
|
||||
? rawMessage.content
|
||||
: safeJsonStringify(rawMessage.content ?? '');
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
parts: [{
|
||||
type: 'tool_result',
|
||||
toolCallId: toolCallId || 'tool',
|
||||
...(resultText ? { resultText } : {}),
|
||||
}],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = openAiContentToCanonicalParts(rawMessage.content);
|
||||
const toolCalls = Array.isArray(rawMessage.tool_calls) ? rawMessage.tool_calls : [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!isRecord(toolCall)) continue;
|
||||
const fn = isRecord(toolCall.function) ? toolCall.function : {};
|
||||
const id = asTrimmedString(toolCall.id);
|
||||
const name = asTrimmedString(toolCall.name ?? fn.name);
|
||||
const argumentsJson = typeof fn.arguments === 'string'
|
||||
? fn.arguments
|
||||
: safeJsonStringify(fn.arguments ?? toolCall.arguments ?? {});
|
||||
if (!name) continue;
|
||||
parts.push({
|
||||
type: 'tool_call',
|
||||
id: id || `tool_${parts.length}`,
|
||||
name,
|
||||
argumentsJson,
|
||||
});
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role,
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const reasoningResult = normalizeCanonicalReasoningRequest({
|
||||
include: body.include,
|
||||
reasoning: body.reasoning,
|
||||
reasoning_effort: body.reasoning_effort,
|
||||
reasoning_budget: body.reasoning_budget,
|
||||
reasoning_summary: body.reasoning_summary,
|
||||
});
|
||||
|
||||
const continuation: CanonicalContinuation = {
|
||||
...(normalizeCanonicalContinuation(input.continuation) ?? {}),
|
||||
...(asTrimmedString(body.previous_response_id)
|
||||
? { previousResponseId: asTrimmedString(body.previous_response_id) }
|
||||
: {}),
|
||||
...(asTrimmedString(body.prompt_cache_key)
|
||||
? { promptCacheKey: asTrimmedString(body.prompt_cache_key) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return createCanonicalRequestEnvelope({
|
||||
operation: input.operation ?? 'generate',
|
||||
surface: input.surface,
|
||||
cliProfile: input.cliProfile ?? 'generic',
|
||||
requestedModel: asTrimmedString(body.model),
|
||||
stream: body.stream === true,
|
||||
messages,
|
||||
...(reasoningResult.reasoning ? { reasoning: reasoningResult.reasoning } : {}),
|
||||
...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}),
|
||||
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
||||
...(Object.keys(continuation).length > 0 ? { continuation } : {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
...((reasoningResult.metadata || input.passthrough)
|
||||
? {
|
||||
passthrough: {
|
||||
...(input.passthrough ?? {}),
|
||||
...(reasoningResult.metadata ? { transformerMetadata: reasoningResult.metadata } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalPartsToOpenAiContent(
|
||||
role: CanonicalMessageRole,
|
||||
parts: CanonicalContentPart[],
|
||||
): { content: string | Array<Record<string, unknown>>; reasoning?: string; toolCalls?: Array<Record<string, unknown>> } {
|
||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||
const toolCalls: Array<Record<string, unknown>> = [];
|
||||
const visibleText: string[] = [];
|
||||
const reasoningText: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
if (part.thought === true) {
|
||||
reasoningText.push(part.text);
|
||||
} else {
|
||||
visibleText.push(part.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'image') {
|
||||
const url = asTrimmedString(part.url ?? part.dataUrl);
|
||||
if (url) {
|
||||
contentBlocks.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'file') {
|
||||
const normalizedFile = canonicalAttachmentToNormalizedInputFile({
|
||||
kind: 'file',
|
||||
...(part.fileId ? { fileId: part.fileId } : {}),
|
||||
...(part.fileUrl ? { fileUrl: part.fileUrl } : {}),
|
||||
...(part.fileData ? { fileData: part.fileData } : {}),
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
|
||||
});
|
||||
contentBlocks.push(toOpenAiChatFileBlock(normalizedFile));
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_call') {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: part.argumentsJson,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === 'tool_result' && role !== 'tool') {
|
||||
const text = part.resultText ?? safeJsonStringify(part.resultJson ?? '');
|
||||
if (text) {
|
||||
visibleText.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length <= 0) {
|
||||
return {
|
||||
content: visibleText.join(''),
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (visibleText.length > 0) {
|
||||
contentBlocks.unshift({
|
||||
type: 'text',
|
||||
text: visibleText.join(''),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
content: contentBlocks,
|
||||
...(reasoningText.length > 0 ? { reasoning: reasoningText.join('') } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalToolChoiceToOpenAi(toolChoice: CanonicalToolChoice | undefined): unknown {
|
||||
if (!toolChoice) return undefined;
|
||||
if (toolChoice === 'auto' || toolChoice === 'none') return toolChoice;
|
||||
if (toolChoice === 'required') return 'required';
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolChoice.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function canonicalRequestToOpenAiChatBody(
|
||||
request: CanonicalRequestEnvelope,
|
||||
): Record<string, unknown> {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'tool') {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool_result') continue;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: part.toolCallId,
|
||||
content: part.resultText ?? safeJsonStringify(part.resultJson ?? ''),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = canonicalPartsToOpenAiContent(message.role, message.parts);
|
||||
const nextMessage: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: converted.content,
|
||||
};
|
||||
if (message.role === 'assistant' && converted.reasoning) {
|
||||
nextMessage.reasoning_content = converted.reasoning;
|
||||
}
|
||||
if (message.role === 'assistant' && converted.toolCalls && converted.toolCalls.length > 0) {
|
||||
nextMessage.tool_calls = converted.toolCalls;
|
||||
if (typeof nextMessage.content !== 'string' && (nextMessage.content as Array<unknown>).length <= 0) {
|
||||
nextMessage.content = '';
|
||||
}
|
||||
}
|
||||
messages.push(nextMessage);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: request.requestedModel,
|
||||
stream: request.stream,
|
||||
messages,
|
||||
};
|
||||
|
||||
if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort;
|
||||
if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens;
|
||||
if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary;
|
||||
if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey;
|
||||
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
body.tools = request.tools.map((tool) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||
},
|
||||
}));
|
||||
}
|
||||
const toolChoice = canonicalToolChoiceToOpenAi(request.toolChoice);
|
||||
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
||||
|
||||
if (isRecord(request.passthrough)) {
|
||||
for (const [key, value] of Object.entries(request.passthrough)) {
|
||||
if (key === 'transformerMetadata' || body[key] !== undefined) continue;
|
||||
body[key] = cloneJsonValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export * from './attachments.js';
|
||||
export * from './reasoning.js';
|
||||
export * from './tools.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,14 @@
|
||||
export type CanonicalTool = {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CanonicalToolChoice =
|
||||
| 'auto'
|
||||
| 'none'
|
||||
| 'required'
|
||||
| {
|
||||
type: 'tool';
|
||||
name: string;
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CanonicalTool, CanonicalToolChoice } from './tools.js';
|
||||
|
||||
export type CanonicalOperation =
|
||||
| 'generate'
|
||||
| 'count_tokens';
|
||||
|
||||
export type CanonicalSurface =
|
||||
| 'openai-chat'
|
||||
| 'openai-responses'
|
||||
| 'anthropic-messages'
|
||||
| 'gemini-generate-content';
|
||||
|
||||
export type CanonicalCliProfile =
|
||||
| 'generic'
|
||||
| 'codex'
|
||||
| 'claude_code'
|
||||
| 'gemini_cli';
|
||||
|
||||
export type CanonicalMessageRole =
|
||||
| 'system'
|
||||
| 'developer'
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'tool';
|
||||
|
||||
export type CanonicalTextPart = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
thought?: boolean;
|
||||
};
|
||||
|
||||
export type CanonicalImagePart = {
|
||||
type: 'image';
|
||||
dataUrl?: string;
|
||||
url?: string;
|
||||
mimeType?: string | null;
|
||||
};
|
||||
|
||||
export type CanonicalFilePart = {
|
||||
type: 'file';
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
fileData?: string;
|
||||
mimeType?: string | null;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export type CanonicalToolCallPart = {
|
||||
type: 'tool_call';
|
||||
id: string;
|
||||
name: string;
|
||||
argumentsJson: string;
|
||||
};
|
||||
|
||||
export type CanonicalToolResultPart = {
|
||||
type: 'tool_result';
|
||||
toolCallId: string;
|
||||
resultText?: string;
|
||||
resultJson?: unknown;
|
||||
};
|
||||
|
||||
export type CanonicalContentPart =
|
||||
| CanonicalTextPart
|
||||
| CanonicalImagePart
|
||||
| CanonicalFilePart
|
||||
| CanonicalToolCallPart
|
||||
| CanonicalToolResultPart;
|
||||
|
||||
export type CanonicalMessage = {
|
||||
role: CanonicalMessageRole;
|
||||
parts: CanonicalContentPart[];
|
||||
};
|
||||
|
||||
export type CanonicalReasoningEffort =
|
||||
| 'none'
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'max';
|
||||
|
||||
export type CanonicalReasoningRequest = {
|
||||
effort?: CanonicalReasoningEffort;
|
||||
budgetTokens?: number;
|
||||
summary?: string;
|
||||
includeEncryptedContent?: boolean;
|
||||
};
|
||||
|
||||
export type CanonicalContinuation = {
|
||||
sessionId?: string;
|
||||
previousResponseId?: string;
|
||||
promptCacheKey?: string;
|
||||
turnState?: string;
|
||||
};
|
||||
|
||||
export type CanonicalRequestEnvelope = {
|
||||
operation: CanonicalOperation;
|
||||
surface: CanonicalSurface;
|
||||
cliProfile: CanonicalCliProfile;
|
||||
requestedModel: string;
|
||||
stream: boolean;
|
||||
messages: CanonicalMessage[];
|
||||
reasoning?: CanonicalReasoningRequest;
|
||||
tools?: CanonicalTool[];
|
||||
toolChoice?: CanonicalToolChoice;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
CanonicalCliProfile,
|
||||
CanonicalContinuation,
|
||||
CanonicalOperation,
|
||||
CanonicalRequestEnvelope,
|
||||
} from './canonical/types.js';
|
||||
import type {
|
||||
ClaudeDownstreamContext,
|
||||
NormalizedFinalResponse,
|
||||
NormalizedStreamEvent,
|
||||
StreamTransformContext,
|
||||
} from './shared/normalized.js';
|
||||
|
||||
export type ProtocolParseContext = {
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
operation?: CanonicalOperation;
|
||||
continuation?: CanonicalContinuation;
|
||||
metadata?: Record<string, unknown>;
|
||||
passthrough?: Record<string, unknown>;
|
||||
defaultEncryptedReasoningInclude?: boolean;
|
||||
};
|
||||
|
||||
export type ProtocolBuildContext = {
|
||||
cliProfile?: CanonicalCliProfile;
|
||||
};
|
||||
|
||||
export type ProtocolResponseContext = {
|
||||
modelName: string;
|
||||
fallbackText?: string;
|
||||
};
|
||||
|
||||
export type ProtocolStreamContext = {
|
||||
modelName: string;
|
||||
streamContext?: StreamTransformContext;
|
||||
};
|
||||
|
||||
export type ProtocolSerializeContext = {
|
||||
modelName: string;
|
||||
usage?: {
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
};
|
||||
streamContext?: StreamTransformContext;
|
||||
claudeContext?: ClaudeDownstreamContext;
|
||||
};
|
||||
|
||||
export interface ProtocolTransformer {
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } };
|
||||
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown>;
|
||||
|
||||
normalizeFinal(
|
||||
payload: unknown,
|
||||
ctx: ProtocolResponseContext,
|
||||
): NormalizedFinalResponse;
|
||||
|
||||
normalizeStreamEvent(
|
||||
payload: unknown,
|
||||
ctx: ProtocolStreamContext,
|
||||
): NormalizedStreamEvent;
|
||||
|
||||
serializeFinal(
|
||||
normalized: NormalizedFinalResponse,
|
||||
ctx: ProtocolSerializeContext,
|
||||
): unknown;
|
||||
|
||||
serializeStreamEvent(
|
||||
normalized: NormalizedStreamEvent,
|
||||
ctx: ProtocolSerializeContext,
|
||||
): string[] | unknown[];
|
||||
}
|
||||
@@ -1,11 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function readWorkspaceFile(relativePath: string): string {
|
||||
return readFileSync(path.resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function listTransformerFiles(directory: string): string[] {
|
||||
const entries = readdirSync(directory);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...listTransformerFiles(fullPath));
|
||||
continue;
|
||||
}
|
||||
if (entry.endsWith('.ts') && !entry.endsWith('.test.ts')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
describe('final transformer hard-cut architecture', () => {
|
||||
it('keeps shared normalized helpers independent from route chatFormats', () => {
|
||||
const sharedNormalized = readWorkspaceFile('src/server/transformers/shared/normalized.ts');
|
||||
@@ -37,4 +54,22 @@ describe('final transformer hard-cut architecture', () => {
|
||||
expect(geminiStream).not.toContain('passthrough');
|
||||
expect(geminiAggregator).not.toContain('parts: unknown[]');
|
||||
});
|
||||
|
||||
it('forbids transformer imports from routes, oauth, token router, runtime executor, and fastify', () => {
|
||||
const transformerRoot = path.resolve(process.cwd(), 'src/server/transformers');
|
||||
const files = listTransformerFiles(transformerRoot);
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toContain("from '../../routes/proxy/");
|
||||
expect(source).not.toContain("from '../routes/proxy/");
|
||||
expect(source).not.toContain("from '../../services/oauth/");
|
||||
expect(source).not.toContain("from '../services/oauth/");
|
||||
expect(source).not.toContain("from '../../services/tokenRouter.js'");
|
||||
expect(source).not.toContain("from '../services/tokenRouter.js'");
|
||||
expect(source).not.toContain("from '../../routes/proxy/runtimeExecutor.js'");
|
||||
expect(source).not.toContain("from '../routes/proxy/runtimeExecutor.js'");
|
||||
expect(source).not.toContain("from 'fastify'");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,86 @@ import {
|
||||
} from './stream.js';
|
||||
|
||||
describe('geminiGenerateContentTransformer.inbound', () => {
|
||||
it('parses native Gemini requests into canonical envelopes', () => {
|
||||
const result = geminiGenerateContentTransformer.parseRequest({
|
||||
model: 'gemini-2.5-pro',
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds native Gemini requests from canonical envelopes', () => {
|
||||
const body = geminiGenerateContentTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: 'gemini_cli',
|
||||
requestedModel: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
budgetTokens: 512,
|
||||
},
|
||||
tools: [{ name: 'lookup', inputSchema: { type: 'object' } }],
|
||||
toolChoice: 'required',
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'lookup',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
},
|
||||
},
|
||||
generationConfig: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 512,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves native Gemini request fields through normalization', () => {
|
||||
const body = geminiGenerateContentTransformer.inbound.normalizeRequest({
|
||||
contents: [
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { canonicalRequestFromOpenAiBody } from '../../canonical/request.js';
|
||||
import type { CanonicalContentPart, CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return (baseUrl || '').replace(/\/+$/, '');
|
||||
}
|
||||
@@ -10,6 +13,10 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function resolveGeminiNativeBaseUrl(baseUrl: string, apiVersion: string): string {
|
||||
const normalized = normalizeBaseUrl(baseUrl);
|
||||
if (baseIncludesVersion(normalized)) return normalized;
|
||||
@@ -85,6 +92,189 @@ import { geminiGenerateContentUsage } from './usage.js';
|
||||
import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js';
|
||||
import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js';
|
||||
|
||||
function parseJsonString(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { raw };
|
||||
}
|
||||
}
|
||||
|
||||
function parseDataUrl(value: string): { mimeType: string; data: string } | null {
|
||||
const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
mimeType: match[1],
|
||||
data: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalPartToGeminiPart(part: CanonicalContentPart): Record<string, unknown> | null {
|
||||
if (part.type === 'text') {
|
||||
return {
|
||||
text: part.text,
|
||||
...(part.thought === true ? { thought: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'image') {
|
||||
const source = typeof part.dataUrl === 'string' && part.dataUrl.trim()
|
||||
? part.dataUrl
|
||||
: (typeof part.url === 'string' ? part.url : '');
|
||||
if (!source) return null;
|
||||
|
||||
const dataUrl = parseDataUrl(source);
|
||||
if (dataUrl) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: dataUrl.mimeType,
|
||||
data: dataUrl.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: source,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'file') {
|
||||
if (part.fileData) {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
data: part.fileData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileUri = part.fileUrl || part.fileId;
|
||||
if (!fileUri) return null;
|
||||
return {
|
||||
fileData: {
|
||||
fileUri,
|
||||
...(part.mimeType ? { mimeType: part.mimeType } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_call') {
|
||||
return {
|
||||
functionCall: {
|
||||
id: part.id,
|
||||
name: part.name,
|
||||
args: parseJsonString(part.argumentsJson),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (part.type === 'tool_result') {
|
||||
return {
|
||||
functionResponse: {
|
||||
name: part.toolCallId,
|
||||
response: part.resultJson ?? parseJsonString(part.resultText ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildGeminiRequestFromCanonical(request: CanonicalRequestEnvelope): Record<string, unknown> {
|
||||
const contents: Array<Record<string, unknown>> = [];
|
||||
const systemParts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'system' || message.role === 'developer') {
|
||||
systemParts.push(
|
||||
...message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parts = message.parts
|
||||
.map((part) => canonicalPartToGeminiPart(part))
|
||||
.filter((part): part is Record<string, unknown> => !!part);
|
||||
|
||||
if (parts.length <= 0) continue;
|
||||
|
||||
if (message.role === 'tool') {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: message.role === 'assistant' ? 'model' : 'user',
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
contents,
|
||||
};
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
payload.systemInstruction = {
|
||||
role: 'user',
|
||||
parts: systemParts,
|
||||
};
|
||||
}
|
||||
|
||||
const generationConfig: Record<string, unknown> = {};
|
||||
if (request.reasoning?.budgetTokens !== undefined) {
|
||||
generationConfig.thinkingConfig = {
|
||||
thinkingBudget: request.reasoning.budgetTokens,
|
||||
};
|
||||
} else if (request.reasoning?.effort) {
|
||||
generationConfig.thinkingConfig = reasoningEffortToGeminiThinkingConfig(
|
||||
request.requestedModel,
|
||||
request.reasoning.effort,
|
||||
);
|
||||
}
|
||||
if (Object.keys(generationConfig).length > 0) {
|
||||
payload.generationConfig = generationConfig;
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||
payload.tools = [{
|
||||
functionDeclarations: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
...(tool.description ? { description: tool.description } : {}),
|
||||
...(tool.inputSchema ? { parameters: tool.inputSchema } : {}),
|
||||
})),
|
||||
}];
|
||||
}
|
||||
|
||||
if (request.toolChoice) {
|
||||
if (request.toolChoice === 'none') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'NONE' } };
|
||||
} else if (request.toolChoice === 'auto') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'AUTO' } };
|
||||
} else if (request.toolChoice === 'required') {
|
||||
payload.toolConfig = { functionCallingConfig: { mode: 'ANY' } };
|
||||
} else {
|
||||
payload.toolConfig = {
|
||||
functionCallingConfig: {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: [request.toolChoice.name],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const geminiGenerateContentTransformer = {
|
||||
protocol: 'gemini/generate-content' as const,
|
||||
inbound: geminiGenerateContentInbound,
|
||||
@@ -108,6 +298,52 @@ export const geminiGenerateContentTransformer = {
|
||||
resolveBaseUrl: resolveGeminiNativeBaseUrl,
|
||||
resolveModelsUrl: resolveGeminiModelsUrl,
|
||||
resolveActionUrl: resolveGeminiGenerateContentUrl,
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const rawBody = isRecord(body) ? body : {};
|
||||
const requestedModel = asTrimmedString(rawBody.model ?? ctx?.metadata?.requestedModel);
|
||||
if (!requestedModel) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const stream = rawBody.stream === true || ctx?.metadata?.stream === true;
|
||||
const normalizedBody = geminiGenerateContentInbound.normalizeRequest(rawBody, requestedModel);
|
||||
const openAiBody = buildOpenAiBodyFromGeminiRequest({
|
||||
body: normalizedBody,
|
||||
modelName: requestedModel,
|
||||
stream,
|
||||
});
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'gemini-generate-content',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return buildGeminiRequestFromCanonical(request);
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -11,6 +11,66 @@ function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
|
||||
}
|
||||
|
||||
describe('openAiChatTransformer.inbound', () => {
|
||||
it('parses chat requests into canonical envelopes', () => {
|
||||
const result = openAiChatTransformer.parseRequest({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning_effort: 'high',
|
||||
reasoning_budget: 1024,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
continuation: {
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
budgetTokens: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds chat requests from canonical envelopes', () => {
|
||||
const body = openAiChatTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'openai-chat',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
reasoning: {
|
||||
effort: 'medium',
|
||||
budgetTokens: 512,
|
||||
},
|
||||
continuation: {
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning_effort: 'medium',
|
||||
reasoning_budget: 512,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures chat request metadata fields without changing upstream body', () => {
|
||||
const result = openAiChatTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type NormalizedFinalResponse, type NormalizedStreamEvent, type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import { createChatEndpointStrategy } from '../../shared/chatEndpointStrategy.js';
|
||||
import { openAiChatInbound } from './inbound.js';
|
||||
@@ -28,6 +31,46 @@ export const openAiChatTransformer = {
|
||||
proxyStream: {
|
||||
createSession: createChatProxyStreamSession,
|
||||
},
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiChatInbound.parse(body);
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid chat request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: parsed.value.parsed.upstreamBody,
|
||||
surface: 'openai-chat',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
return canonicalRequestToOpenAiChatBody(request);
|
||||
},
|
||||
transformRequest(body: unknown): ReturnType<typeof openAiChatInbound.parse> {
|
||||
return openAiChatInbound.parse(body);
|
||||
},
|
||||
|
||||
@@ -3,6 +3,75 @@ import { describe, expect, it } from 'vitest';
|
||||
import { openAiResponsesTransformer } from './index.js';
|
||||
|
||||
describe('openAiResponsesTransformer.inbound', () => {
|
||||
it('parses responses requests into canonical envelopes', () => {
|
||||
const result = openAiResponsesTransformer.parseRequest({
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key',
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.value).toMatchObject({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'generic',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: false,
|
||||
continuation: {
|
||||
previousResponseId: 'resp_prev_1',
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds responses requests from canonical envelopes', () => {
|
||||
const body = openAiResponsesTransformer.buildProtocolRequest({
|
||||
operation: 'generate',
|
||||
surface: 'openai-responses',
|
||||
cliProfile: 'codex',
|
||||
requestedModel: 'gpt-5',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }],
|
||||
continuation: {
|
||||
previousResponseId: 'resp_prev_1',
|
||||
promptCacheKey: 'cache-key',
|
||||
},
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(body).toMatchObject({
|
||||
model: 'gpt-5',
|
||||
stream: true,
|
||||
previous_response_id: 'resp_prev_1',
|
||||
prompt_cache_key: 'cache-key',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
reasoning: {
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a protocol request envelope with a normalized responses body', () => {
|
||||
const result = openAiResponsesTransformer.transformRequest({
|
||||
model: 'gpt-5',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { canonicalRequestFromOpenAiBody, canonicalRequestToOpenAiChatBody } from '../../canonical/request.js';
|
||||
import type { CanonicalRequestEnvelope } from '../../canonical/types.js';
|
||||
import type { ProtocolBuildContext, ProtocolParseContext } from '../../contracts.js';
|
||||
import { type StreamTransformContext } from '../../shared/normalized.js';
|
||||
import {
|
||||
convertOpenAiBodyToResponsesBody,
|
||||
@@ -61,6 +64,64 @@ export const openAiResponsesTransformer = {
|
||||
proxyStream: {
|
||||
createSession: createResponsesProxyStreamSession,
|
||||
},
|
||||
parseRequest(
|
||||
body: unknown,
|
||||
ctx?: ProtocolParseContext,
|
||||
): { value?: CanonicalRequestEnvelope; error?: { statusCode: number; payload: unknown } } {
|
||||
const parsed = openAiResponsesInbound.parse(body, {
|
||||
defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
if (parsed.error) {
|
||||
return { error: parsed.error };
|
||||
}
|
||||
if (!parsed.value) {
|
||||
return {
|
||||
error: {
|
||||
statusCode: 400,
|
||||
payload: {
|
||||
error: {
|
||||
message: 'invalid responses request',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const responsesBody = parsed.value.parsed.normalizedBody;
|
||||
const openAiBody = convertResponsesBodyToOpenAiBody(
|
||||
responsesBody,
|
||||
typeof responsesBody.model === 'string' ? responsesBody.model : parsed.value.model,
|
||||
responsesBody.stream === true,
|
||||
{ defaultEncryptedReasoningInclude: ctx?.defaultEncryptedReasoningInclude },
|
||||
);
|
||||
|
||||
return {
|
||||
value: canonicalRequestFromOpenAiBody({
|
||||
body: openAiBody,
|
||||
surface: 'openai-responses',
|
||||
cliProfile: ctx?.cliProfile,
|
||||
operation: ctx?.operation,
|
||||
metadata: ctx?.metadata,
|
||||
passthrough: ctx?.passthrough,
|
||||
continuation: ctx?.continuation,
|
||||
}),
|
||||
};
|
||||
},
|
||||
buildProtocolRequest(
|
||||
request: CanonicalRequestEnvelope,
|
||||
_ctx?: ProtocolBuildContext,
|
||||
): Record<string, unknown> {
|
||||
const openAiBody = canonicalRequestToOpenAiChatBody(request);
|
||||
if (request.reasoning) {
|
||||
openAiBody.reasoning = {
|
||||
...(request.reasoning.effort ? { effort: request.reasoning.effort } : {}),
|
||||
...(request.reasoning.budgetTokens !== undefined ? { budget_tokens: request.reasoning.budgetTokens } : {}),
|
||||
...(request.reasoning.summary ? { summary: request.reasoning.summary } : {}),
|
||||
};
|
||||
}
|
||||
return convertOpenAiBodyToResponsesBody(openAiBody, request.requestedModel, request.stream);
|
||||
},
|
||||
transformRequest(
|
||||
body: unknown,
|
||||
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createResponsesProxyStreamSession } from './proxyStream.js';
|
||||
|
||||
describe('createResponsesProxyStreamSession', () => {
|
||||
it('serializes non-SSE fallback payloads into canonical responses SSE closeout events', () => {
|
||||
const lines: string[] = [];
|
||||
let ended = false;
|
||||
const usage = {
|
||||
promptTokens: 5,
|
||||
completionTokens: 3,
|
||||
totalTokens: 8,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
promptTokensIncludeCache: null,
|
||||
};
|
||||
const payload = {
|
||||
id: 'resp_fallback_1',
|
||||
object: 'response',
|
||||
status: 'completed',
|
||||
model: 'gpt-5.2',
|
||||
output_text: 'hello from responses upstream',
|
||||
output: [
|
||||
{
|
||||
id: 'msg_fallback_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: usage.promptTokens,
|
||||
output_tokens: usage.completionTokens,
|
||||
total_tokens: usage.totalTokens,
|
||||
},
|
||||
};
|
||||
|
||||
const session = createResponsesProxyStreamSession({
|
||||
modelName: 'gpt-5.2',
|
||||
successfulUpstreamPath: '/v1/responses',
|
||||
getUsage: () => usage,
|
||||
writeLines: (nextLines) => {
|
||||
lines.push(...nextLines);
|
||||
},
|
||||
writeRaw: () => {},
|
||||
});
|
||||
|
||||
const result = session.consumeUpstreamFinalPayload(
|
||||
payload,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
end() {
|
||||
ended = true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
});
|
||||
expect(ended).toBe(true);
|
||||
|
||||
const output = lines.join('');
|
||||
expect(output).toContain('event: response.created');
|
||||
expect(output).toContain('event: response.completed');
|
||||
expect(output).toContain('"type":"response.completed"');
|
||||
expect(output).toContain('"output_text":"hello from responses upstream"');
|
||||
expect(output).toContain('data: [DONE]');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createProxyStreamLifecycle } from '../../shared/protocolLifecycle.js';
|
||||
import { type ParsedSseEvent } from '../../shared/normalized.js';
|
||||
import { completeResponsesStream, createOpenAiResponsesAggregateState, failResponsesStream, serializeConvertedResponsesEvents } from './aggregator.js';
|
||||
import { openAiResponsesOutbound } from './outbound.js';
|
||||
import { openAiResponsesStream } from './stream.js';
|
||||
|
||||
type StreamReader = {
|
||||
@@ -57,6 +58,9 @@ function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream
|
||||
export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) {
|
||||
const streamContext = openAiResponsesStream.createContext(input.modelName);
|
||||
const responsesState = createOpenAiResponsesAggregateState(input.modelName);
|
||||
const requiresExplicitTerminalEvent = input.strictTerminalEvents
|
||||
|| input.successfulUpstreamPath.endsWith('/responses')
|
||||
|| input.successfulUpstreamPath.endsWith('/responses/compact');
|
||||
let finalized = false;
|
||||
let terminalResult: ResponsesProxyStreamResult = {
|
||||
status: 'completed',
|
||||
@@ -83,14 +87,24 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), payload));
|
||||
};
|
||||
|
||||
const complete = () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
};
|
||||
|
||||
const closeOut = () => {
|
||||
if (finalized) return;
|
||||
if (input.strictTerminalEvents) {
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'failed',
|
||||
errorMessage: 'stream closed before response.completed',
|
||||
};
|
||||
if (requiresExplicitTerminalEvent) {
|
||||
fail({
|
||||
type: 'response.failed',
|
||||
error: {
|
||||
message: 'stream closed before response.completed',
|
||||
},
|
||||
}, 'stream closed before response.completed');
|
||||
return;
|
||||
}
|
||||
finalize();
|
||||
@@ -119,8 +133,10 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
const isFailureEvent = (
|
||||
eventBlock.event === 'error'
|
||||
|| eventBlock.event === 'response.failed'
|
||||
|| eventBlock.event === 'response.incomplete'
|
||||
|| payloadType === 'error'
|
||||
|| payloadType === 'response.failed'
|
||||
|| payloadType === 'response.incomplete'
|
||||
);
|
||||
if (isFailureEvent) {
|
||||
fail(parsedPayload);
|
||||
@@ -135,6 +151,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
event: normalizedEvent,
|
||||
usage: input.getUsage(),
|
||||
}));
|
||||
if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') {
|
||||
complete();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -148,6 +167,51 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
||||
};
|
||||
|
||||
return {
|
||||
consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ResponsesProxyStreamResult {
|
||||
if (payload && typeof payload === 'object') {
|
||||
input.onParsedPayload?.(payload);
|
||||
}
|
||||
|
||||
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
||||
? payload.type
|
||||
: '';
|
||||
if (payloadType === 'error' || payloadType === 'response.failed') {
|
||||
fail(payload);
|
||||
response?.end();
|
||||
return terminalResult;
|
||||
}
|
||||
|
||||
const normalizedFinal = openAiResponsesOutbound.normalizeFinal(payload, input.modelName, fallbackText);
|
||||
streamContext.id = normalizedFinal.id;
|
||||
streamContext.model = normalizedFinal.model;
|
||||
streamContext.created = normalizedFinal.created;
|
||||
|
||||
const streamPayload = openAiResponsesOutbound.serializeFinal({
|
||||
upstreamPayload: payload,
|
||||
normalized: normalizedFinal,
|
||||
usage: input.getUsage(),
|
||||
serializationMode: 'response',
|
||||
});
|
||||
const createdPayload = {
|
||||
...streamPayload,
|
||||
status: 'in_progress',
|
||||
output: [],
|
||||
output_text: '',
|
||||
};
|
||||
|
||||
finalized = true;
|
||||
terminalResult = {
|
||||
status: 'completed',
|
||||
errorMessage: null,
|
||||
};
|
||||
input.writeLines([
|
||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`,
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
response?.end();
|
||||
return terminalResult;
|
||||
},
|
||||
async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise<ResponsesProxyStreamResult> {
|
||||
const lifecycle = createProxyStreamLifecycle<ParsedSseEvent>({
|
||||
reader,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Response as UndiciResponse } from 'undici';
|
||||
import type { DownstreamFormat } from './normalized.js';
|
||||
import type { EndpointAttemptContext, EndpointRecoverResult } from '../../routes/proxy/endpointFlow.js';
|
||||
import {
|
||||
buildMinimalJsonHeadersForCompatibility,
|
||||
isEndpointDispatchDeniedError,
|
||||
@@ -13,6 +13,18 @@ import {
|
||||
shouldRetryNormalizedMessagesBody,
|
||||
} from '../anthropic/messages/compatibility.js';
|
||||
|
||||
type EndpointAttemptContext = {
|
||||
request: CompatibilityRequest;
|
||||
targetUrl: string;
|
||||
response: UndiciResponse;
|
||||
rawErrText: string;
|
||||
};
|
||||
|
||||
type EndpointRecoverResult = {
|
||||
upstream: UndiciResponse;
|
||||
upstreamPath: string;
|
||||
} | null;
|
||||
|
||||
type CompatibilityRequest = {
|
||||
endpoint: CompatibilityEndpoint;
|
||||
path: string;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import ImportExport from './ImportExport.js';
|
||||
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
exportBackup: vi.fn(),
|
||||
importBackup: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const allApiHubV2Payload = JSON.stringify({
|
||||
version: '2.0',
|
||||
timestamp: 1735689600000,
|
||||
accounts: {
|
||||
accounts: [
|
||||
{
|
||||
id: 'managed-account',
|
||||
site_url: 'https://newapi.example.com',
|
||||
site_type: 'new-api',
|
||||
site_name: 'Managed Site',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
id: 7788,
|
||||
username: 'managed-user',
|
||||
access_token: 'managed-session-token',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'direct-account',
|
||||
site_url: 'https://api.openai.com',
|
||||
site_type: 'openai',
|
||||
site_name: 'OpenAI Direct',
|
||||
authType: 'access_token',
|
||||
account_info: {
|
||||
username: 'openai-user',
|
||||
access_token: 'sk-openai-account',
|
||||
},
|
||||
},
|
||||
],
|
||||
bookmarks: [
|
||||
{
|
||||
id: 'bookmark-1',
|
||||
name: 'Ignored Bookmark',
|
||||
url: 'https://bookmark.example.com',
|
||||
},
|
||||
],
|
||||
pinnedAccountIds: ['direct-account'],
|
||||
orderedAccountIds: ['managed-account', 'direct-account'],
|
||||
last_updated: 1735689601000,
|
||||
},
|
||||
preferences: {
|
||||
language: 'zh-CN',
|
||||
},
|
||||
channelConfigs: {
|
||||
bySite: {
|
||||
demo: { enabled: true },
|
||||
},
|
||||
},
|
||||
tagStore: {
|
||||
version: 1,
|
||||
tagsById: {},
|
||||
},
|
||||
apiCredentialProfiles: {
|
||||
version: 2,
|
||||
profiles: [
|
||||
{
|
||||
id: 'profile-openai',
|
||||
name: 'OpenAI Profile',
|
||||
apiType: 'openai',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
apiKey: 'sk-profile-openai',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689602000,
|
||||
updatedAt: 1735689603000,
|
||||
},
|
||||
{
|
||||
id: 'profile-gemini',
|
||||
name: 'Gemini Profile',
|
||||
apiType: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'gemini-profile-key',
|
||||
tagIds: [],
|
||||
notes: '',
|
||||
createdAt: 1735689604000,
|
||||
updatedAt: 1735689605000,
|
||||
},
|
||||
],
|
||||
lastUpdated: 1735689606000,
|
||||
},
|
||||
});
|
||||
|
||||
const nativeMetapiPayload = JSON.stringify({
|
||||
version: '2.0',
|
||||
timestamp: 1735689600000,
|
||||
accounts: {
|
||||
sites: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Native Site',
|
||||
url: 'https://native.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'native-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: 'api-token',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
accountTokens: [
|
||||
{
|
||||
id: 1,
|
||||
accountId: 1,
|
||||
name: 'default',
|
||||
token: 'sk-native',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
tokenRoutes: [
|
||||
{
|
||||
id: 1,
|
||||
modelPattern: 'gpt-5-nano',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
routeChannels: [
|
||||
{
|
||||
id: 1,
|
||||
routeId: 1,
|
||||
accountId: 1,
|
||||
tokenId: 1,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
},
|
||||
],
|
||||
routeGroupSources: [],
|
||||
},
|
||||
preferences: {
|
||||
settings: [
|
||||
{ key: 'locale', value: 'zh-CN' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('ImportExport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', {
|
||||
confirm: vi.fn(() => true),
|
||||
});
|
||||
apiMock.importBackup.mockResolvedValue({
|
||||
allImported: true,
|
||||
sections: {
|
||||
accounts: true,
|
||||
preferences: true,
|
||||
},
|
||||
appliedSettings: [],
|
||||
summary: {
|
||||
importedSites: 3,
|
||||
importedAccounts: 2,
|
||||
importedProfiles: 2,
|
||||
importedApiKeyConnections: 3,
|
||||
skippedAccounts: 1,
|
||||
ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'],
|
||||
},
|
||||
warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = collectText(root!.root);
|
||||
expect(rendered).toContain('ALL-API-Hub V2');
|
||||
expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2');
|
||||
expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not label native metapi backups as ALL-API-Hub V2', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: nativeMetapiPayload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = collectText(root!.root);
|
||||
expect(rendered).not.toContain('ALL-API-Hub V2');
|
||||
expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 设置 1');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses backend import summary in the completion toast', async () => {
|
||||
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ImportExport />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const textarea = root!.root.findByType('textarea');
|
||||
await act(async () => {
|
||||
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const importButton = root!.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('导入')
|
||||
)).at(-1);
|
||||
|
||||
expect(importButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
importButton!.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
|
||||
expect(toastMock.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
|
||||
);
|
||||
expect(toastMock.success).toHaveBeenCalledWith(
|
||||
expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'),
|
||||
);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
+111
-33
@@ -12,12 +12,16 @@ type ParsedSummary = {
|
||||
hasAccounts: boolean;
|
||||
hasPreferences: boolean;
|
||||
hasLegacyData: boolean;
|
||||
isAllApiHubV2: boolean;
|
||||
sitesCount: number;
|
||||
accountsCount: number;
|
||||
bookmarksCount: number;
|
||||
profilesCount: number;
|
||||
tokensCount: number;
|
||||
routesCount: number;
|
||||
channelsCount: number;
|
||||
settingsCount: number;
|
||||
ignoredSections: string[];
|
||||
};
|
||||
|
||||
function downloadJsonFile(data: unknown, filename: string) {
|
||||
@@ -35,23 +39,29 @@ function downloadJsonFile(data: unknown, filename: string) {
|
||||
function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
const invalidSummary = (): ParsedSummary => ({
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
isAllApiHubV2: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
bookmarksCount: 0,
|
||||
profilesCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
ignoredSections: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw) as any;
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return {
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
};
|
||||
return invalidSummary();
|
||||
}
|
||||
|
||||
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
|
||||
@@ -63,6 +73,38 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
|
||||
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
|
||||
const legacyPrefs = Boolean(data.data?.preferences);
|
||||
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
|
||||
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
|
||||
const isNativeMetapiBackup = Boolean(
|
||||
accountsSection
|
||||
&& Array.isArray(accountsSection.sites)
|
||||
&& Array.isArray(accountsSection.accountTokens)
|
||||
&& Array.isArray(accountsSection.tokenRoutes)
|
||||
&& Array.isArray(accountsSection.routeChannels)
|
||||
);
|
||||
const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts)
|
||||
&& accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && (
|
||||
'site_url' in row
|
||||
|| 'site_type' in row
|
||||
|| 'account_info' in row
|
||||
|| 'cookieAuth' in row
|
||||
|| 'authType' in row
|
||||
|| 'sub2apiAuth' in row
|
||||
));
|
||||
const isAllApiHubV2 = Boolean(
|
||||
accountsSection
|
||||
&& !isNativeMetapiBackup
|
||||
&& hasLegacyAccountRows
|
||||
&& Array.isArray(accountsSection.accounts)
|
||||
&& (
|
||||
(typeof data.version === 'string' && data.version.startsWith('2'))
|
||||
|| 'last_updated' in accountsSection
|
||||
|| Array.isArray(accountsSection.bookmarks)
|
||||
|| Array.isArray(accountsSection.pinnedAccountIds)
|
||||
|| Array.isArray(accountsSection.orderedAccountIds)
|
||||
|| profilesCount > 0
|
||||
)
|
||||
);
|
||||
|
||||
const hasAccounts = Boolean(
|
||||
data.type === 'accounts'
|
||||
@@ -74,6 +116,10 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
|| preferencesSection
|
||||
|| legacyPrefs,
|
||||
);
|
||||
const ignoredSections: string[] = [];
|
||||
if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks');
|
||||
if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs');
|
||||
if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore');
|
||||
|
||||
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
|
||||
|
||||
@@ -89,31 +135,52 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|
||||
hasAccounts,
|
||||
hasPreferences,
|
||||
hasLegacyData: legacyAccounts || legacyPrefs,
|
||||
isAllApiHubV2,
|
||||
sitesCount: toCount(accountsSection?.sites),
|
||||
accountsCount: toCount(accountsSection?.accounts),
|
||||
bookmarksCount,
|
||||
profilesCount,
|
||||
tokensCount: toCount(accountsSection?.accountTokens),
|
||||
routesCount: toCount(accountsSection?.tokenRoutes),
|
||||
channelsCount: toCount(accountsSection?.routeChannels),
|
||||
settingsCount: toCount(preferencesSection?.settings),
|
||||
ignoredSections,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
version: '-',
|
||||
timestampLabel: '未知',
|
||||
hasAccounts: false,
|
||||
hasPreferences: false,
|
||||
hasLegacyData: false,
|
||||
sitesCount: 0,
|
||||
accountsCount: 0,
|
||||
tokensCount: 0,
|
||||
routesCount: 0,
|
||||
channelsCount: 0,
|
||||
settingsCount: 0,
|
||||
};
|
||||
return invalidSummary();
|
||||
}
|
||||
}
|
||||
|
||||
function buildImportSuccessMessage(result: any): string {
|
||||
const sections: string[] = [];
|
||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
||||
if (result?.sections?.preferences) sections.push('系统设置');
|
||||
|
||||
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
|
||||
if (result?.summary) {
|
||||
const summary = result.summary;
|
||||
parts.push(
|
||||
[
|
||||
`站点 ${summary.importedSites ?? 0}`,
|
||||
`账号 ${summary.importedAccounts ?? 0}`,
|
||||
`API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`,
|
||||
`跳过 ${summary.skippedAccounts ?? 0}`,
|
||||
].join(' / '),
|
||||
);
|
||||
|
||||
if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) {
|
||||
parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
|
||||
const preview = result.warnings.slice(0, 2).join(';');
|
||||
parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`);
|
||||
}
|
||||
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
export default function ImportExport() {
|
||||
const toast = useToast();
|
||||
const [exportingType, setExportingType] = useState<BackupType | ''>('');
|
||||
@@ -193,7 +260,10 @@ export default function ImportExport() {
|
||||
toast.error('当前 JSON 结构无法识别');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('导入会覆盖账号/路由或系统设置,确认继续?')) {
|
||||
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
|
||||
? true
|
||||
: window.confirm('导入会覆盖账号/路由或系统设置,确认继续?');
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,10 +271,7 @@ export default function ImportExport() {
|
||||
try {
|
||||
const parsed = JSON.parse(importData);
|
||||
const result = await api.importBackup(parsed);
|
||||
const sections: string[] = [];
|
||||
if (result?.sections?.accounts) sections.push('账号与路由');
|
||||
if (result?.sections?.preferences) sections.push('系统设置');
|
||||
toast.success(`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`);
|
||||
toast.success(buildImportSuccessMessage(result));
|
||||
setImportData('');
|
||||
setSelectedFileName('');
|
||||
} catch (err: any) {
|
||||
@@ -379,6 +446,17 @@ export default function ImportExport() {
|
||||
<>
|
||||
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
|
||||
<div>包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
|
||||
{summary.isAllApiHubV2 ? (
|
||||
<>
|
||||
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
|
||||
<div>
|
||||
统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount}
|
||||
</div>
|
||||
{summary.ignoredSections.length ? (
|
||||
<div>不会原生导入:{summary.ignoredSections.join('、')}</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
|
||||
<div>
|
||||
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount}
|
||||
|
||||
@@ -563,6 +563,15 @@ export default function TokenRoutes() {
|
||||
}) as [string, number][];
|
||||
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
||||
|
||||
const sourceEndpointTypesByRouteId = useMemo(() => {
|
||||
const next: Record<number, string[]> = {};
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
next[route.id] = Array.from(routeEndpointTypesByRouteId[route.id] || new Set<string>())
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
return next;
|
||||
}, [exactSourceRouteOptions, routeEndpointTypesByRouteId]);
|
||||
|
||||
const routeBrandIconCandidates = useMemo(() => {
|
||||
const byIcon = new Map<string, BrandInfo>();
|
||||
|
||||
@@ -600,6 +609,7 @@ export default function TokenRoutes() {
|
||||
brand: routeBrandById.get(route.id) || null,
|
||||
modelPattern: route.modelPattern,
|
||||
channelCount: route.channelCount,
|
||||
sourceRouteCount: Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
|
||||
@@ -1152,6 +1162,7 @@ export default function TokenRoutes() {
|
||||
routeIconSelectOptions={routeIconSelectOptions}
|
||||
previewModelSamples={previewModelSamples}
|
||||
exactSourceRouteOptions={exactSourceRouteOptions}
|
||||
sourceEndpointTypesByRouteId={sourceEndpointTypesByRouteId}
|
||||
onSave={handleAddRoute}
|
||||
onCancel={handleCancelEditRoute}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useEffect, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react';
|
||||
import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
|
||||
import CenteredModal from '../../components/CenteredModal.js';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
import { tr } from '../../i18n.js';
|
||||
@@ -11,6 +12,9 @@ import {
|
||||
matchesModelPattern,
|
||||
normalizeRouteDisplayIconValue,
|
||||
normalizeRouteMode,
|
||||
resolveEndpointTypeIconModel,
|
||||
resolveRouteBrand,
|
||||
siteAvatarLetters,
|
||||
} from './utils.js';
|
||||
|
||||
type RouteEditorForm = {
|
||||
@@ -32,6 +36,7 @@ type ManualRoutePanelProps = {
|
||||
routeIconSelectOptions: RouteIconOption[];
|
||||
previewModelSamples: string[];
|
||||
exactSourceRouteOptions: RouteSummaryRow[];
|
||||
sourceEndpointTypesByRouteId: Record<number, string[]>;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
@@ -71,6 +76,41 @@ function SearchField({
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
label,
|
||||
count,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
count?: number;
|
||||
icon?: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`filter-chip ${active ? 'active' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon ? <span className="filter-chip-icon">{icon}</span> : null}
|
||||
<span className="filter-chip-label">{label}</span>
|
||||
{count !== undefined ? <span className="filter-chip-count">{count}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="route-filter-row">
|
||||
<span className="route-filter-row-label">{label}</span>
|
||||
<div className="route-filter-row-chips">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManualRoutePanel({
|
||||
show,
|
||||
editingRouteId,
|
||||
@@ -81,18 +121,25 @@ export default function ManualRoutePanel({
|
||||
routeIconSelectOptions,
|
||||
previewModelSamples,
|
||||
exactSourceRouteOptions,
|
||||
sourceEndpointTypesByRouteId,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ManualRoutePanelProps) {
|
||||
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
||||
const [sourceSearch, setSourceSearch] = useState('');
|
||||
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
|
||||
const [activeSourceBrand, setActiveSourceBrand] = useState<string | null>(null);
|
||||
const [activeSourceSite, setActiveSourceSite] = useState<string | null>(null);
|
||||
const [activeSourceEndpointType, setActiveSourceEndpointType] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setShowSourcePicker(false);
|
||||
setSourceSearch('');
|
||||
setSourcePickerSelection([]);
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
@@ -119,14 +166,136 @@ export default function ManualRoutePanel({
|
||||
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
|
||||
}, [form.modelPattern, modelPatternError, previewModelSamples]);
|
||||
|
||||
const sourceRouteBrandById = useMemo(() => {
|
||||
const next = new Map<number, BrandInfo | null>();
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
next.set(route.id, resolveRouteBrand(route));
|
||||
}
|
||||
return next;
|
||||
}, [exactSourceRouteOptions]);
|
||||
|
||||
const sourceBrandList = useMemo(() => {
|
||||
const grouped = new Map<string, { count: number; brand: BrandInfo }>();
|
||||
let otherCount = 0;
|
||||
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
const brand = sourceRouteBrandById.get(route.id) || null;
|
||||
if (!brand) {
|
||||
otherCount += 1;
|
||||
continue;
|
||||
}
|
||||
const existing = grouped.get(brand.name);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
grouped.set(brand.name, { count: 1, brand });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1].count === b[1].count) {
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b[1].count - a[1].count;
|
||||
}) as [string, { count: number; brand: BrandInfo }][],
|
||||
otherCount,
|
||||
};
|
||||
}, [exactSourceRouteOptions, sourceRouteBrandById]);
|
||||
|
||||
const sourceSiteList = useMemo(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
const seenSites = new Set<string>();
|
||||
for (const siteName of route.siteNames || []) {
|
||||
const normalizedSite = String(siteName || '').trim();
|
||||
if (!normalizedSite || seenSites.has(normalizedSite)) continue;
|
||||
seenSites.add(normalizedSite);
|
||||
grouped.set(normalizedSite, (grouped.get(normalizedSite) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1] === b[1]) {
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b[1] - a[1];
|
||||
}) as [string, number][];
|
||||
}, [exactSourceRouteOptions]);
|
||||
|
||||
const sourceEndpointTypeList = useMemo(() => {
|
||||
const grouped = new Map<string, number>();
|
||||
|
||||
for (const route of exactSourceRouteOptions) {
|
||||
const endpointTypes = sourceEndpointTypesByRouteId[route.id] || [];
|
||||
for (const endpointType of endpointTypes) {
|
||||
const normalizedType = String(endpointType || '').trim();
|
||||
if (!normalizedType) continue;
|
||||
grouped.set(normalizedType, (grouped.get(normalizedType) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...grouped.entries()].sort((a, b) => {
|
||||
if (a[1] === b[1]) {
|
||||
return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b[1] - a[1];
|
||||
}) as [string, number][];
|
||||
}, [exactSourceRouteOptions, sourceEndpointTypesByRouteId]);
|
||||
|
||||
const filteredSourceRoutes = useMemo(() => {
|
||||
let list = [...exactSourceRouteOptions];
|
||||
|
||||
if (activeSourceBrand) {
|
||||
if (activeSourceBrand === '__other__') {
|
||||
list = list.filter((route) => !(sourceRouteBrandById.get(route.id) || null));
|
||||
} else {
|
||||
list = list.filter((route) => (sourceRouteBrandById.get(route.id)?.name || '') === activeSourceBrand);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSourceSite) {
|
||||
list = list.filter((route) => (route.siteNames || []).includes(activeSourceSite));
|
||||
}
|
||||
|
||||
if (activeSourceEndpointType) {
|
||||
list = list.filter((route) => (sourceEndpointTypesByRouteId[route.id] || []).includes(activeSourceEndpointType));
|
||||
}
|
||||
|
||||
const normalizedSearch = sourceSearch.trim().toLowerCase();
|
||||
if (!normalizedSearch) return exactSourceRouteOptions;
|
||||
return exactSourceRouteOptions.filter((route) => {
|
||||
const label = renderRouteOptionLabel(route).toLowerCase();
|
||||
return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch);
|
||||
if (normalizedSearch) {
|
||||
list = list.filter((route) => {
|
||||
const label = renderRouteOptionLabel(route).toLowerCase();
|
||||
const modelPattern = route.modelPattern.toLowerCase();
|
||||
const brandName = (sourceRouteBrandById.get(route.id)?.name || '').toLowerCase();
|
||||
const siteText = (route.siteNames || []).join(' ').toLowerCase();
|
||||
const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).join(' ').toLowerCase();
|
||||
return (
|
||||
label.includes(normalizedSearch)
|
||||
|| modelPattern.includes(normalizedSearch)
|
||||
|| brandName.includes(normalizedSearch)
|
||||
|| siteText.includes(normalizedSearch)
|
||||
|| endpointTypes.includes(normalizedSearch)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return list.sort((a, b) => {
|
||||
if (a.channelCount === b.channelCount) {
|
||||
return renderRouteOptionLabel(a).localeCompare(renderRouteOptionLabel(b), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
return b.channelCount - a.channelCount;
|
||||
});
|
||||
}, [exactSourceRouteOptions, sourceSearch]);
|
||||
}, [
|
||||
activeSourceBrand,
|
||||
activeSourceEndpointType,
|
||||
activeSourceSite,
|
||||
exactSourceRouteOptions,
|
||||
sourceEndpointTypesByRouteId,
|
||||
sourceRouteBrandById,
|
||||
sourceSearch,
|
||||
]);
|
||||
|
||||
const selectedSourceRoutes = useMemo(() => {
|
||||
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
|
||||
@@ -135,18 +304,29 @@ export default function ManualRoutePanel({
|
||||
.filter((route): route is RouteSummaryRow => !!route);
|
||||
}, [exactSourceRouteOptions, form.sourceRouteIds]);
|
||||
|
||||
const sourcePickerSelectionSet = useMemo(
|
||||
() => new Set(sourcePickerSelection),
|
||||
[sourcePickerSelection],
|
||||
);
|
||||
|
||||
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
|
||||
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
|
||||
|
||||
const openSourcePicker = () => {
|
||||
setSourcePickerSelection([...form.sourceRouteIds]);
|
||||
setSourceSearch('');
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
setShowSourcePicker(true);
|
||||
};
|
||||
|
||||
const closeSourcePicker = () => {
|
||||
setShowSourcePicker(false);
|
||||
setSourceSearch('');
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
};
|
||||
|
||||
const confirmSourcePicker = () => {
|
||||
@@ -156,6 +336,9 @@ export default function ManualRoutePanel({
|
||||
}));
|
||||
setShowSourcePicker(false);
|
||||
setSourceSearch('');
|
||||
setActiveSourceBrand(null);
|
||||
setActiveSourceSite(null);
|
||||
setActiveSourceEndpointType(null);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
@@ -584,7 +767,101 @@ export default function ManualRoutePanel({
|
||||
placeholder={tr('搜索来源模型')}
|
||||
/>
|
||||
|
||||
<div style={{ maxHeight: 440, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="route-filter-bar">
|
||||
<div className="route-filter-bar-expanded" style={{ opacity: 1, transform: 'none' }}>
|
||||
<FilterRow label={tr('品牌')}>
|
||||
<FilterChip
|
||||
active={!activeSourceBrand}
|
||||
label={tr('全部')}
|
||||
count={exactSourceRouteOptions.length}
|
||||
icon={<span style={{ fontSize: 10 }}>✦</span>}
|
||||
onClick={() => setActiveSourceBrand(null)}
|
||||
/>
|
||||
{sourceBrandList.list.map(([brandName, { count, brand }]) => (
|
||||
<FilterChip
|
||||
key={brandName}
|
||||
active={activeSourceBrand === brandName}
|
||||
label={brandName}
|
||||
count={count}
|
||||
icon={<BrandGlyph brand={brand} size={12} fallbackText={brandName} />}
|
||||
onClick={() => setActiveSourceBrand(activeSourceBrand === brandName ? null : brandName)}
|
||||
/>
|
||||
))}
|
||||
{sourceBrandList.otherCount > 0 ? (
|
||||
<FilterChip
|
||||
active={activeSourceBrand === '__other__'}
|
||||
label={tr('其他')}
|
||||
count={sourceBrandList.otherCount}
|
||||
icon={<span style={{ fontSize: 10 }}>?</span>}
|
||||
onClick={() => setActiveSourceBrand(activeSourceBrand === '__other__' ? null : '__other__')}
|
||||
/>
|
||||
) : null}
|
||||
</FilterRow>
|
||||
|
||||
{sourceSiteList.length > 0 ? (
|
||||
<FilterRow label={tr('站点')}>
|
||||
<FilterChip
|
||||
active={!activeSourceSite}
|
||||
label={tr('全部')}
|
||||
count={exactSourceRouteOptions.length}
|
||||
icon={<span style={{ fontSize: 10 }}>⚡</span>}
|
||||
onClick={() => setActiveSourceSite(null)}
|
||||
/>
|
||||
{sourceSiteList.map(([siteName, count]) => (
|
||||
<FilterChip
|
||||
key={siteName}
|
||||
active={activeSourceSite === siteName}
|
||||
label={siteName}
|
||||
count={count}
|
||||
icon={(
|
||||
<span
|
||||
style={{
|
||||
fontSize: 8,
|
||||
background: hashColor(siteName),
|
||||
color: 'white',
|
||||
borderRadius: 3,
|
||||
padding: '1px 2px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{siteAvatarLetters(siteName)}
|
||||
</span>
|
||||
)}
|
||||
onClick={() => setActiveSourceSite(activeSourceSite === siteName ? null : siteName)}
|
||||
/>
|
||||
))}
|
||||
</FilterRow>
|
||||
) : null}
|
||||
|
||||
<FilterRow label={tr('能力')}>
|
||||
<FilterChip
|
||||
active={!activeSourceEndpointType}
|
||||
label={tr('全部')}
|
||||
count={exactSourceRouteOptions.length}
|
||||
icon={<span style={{ fontSize: 10 }}>⚙</span>}
|
||||
onClick={() => setActiveSourceEndpointType(null)}
|
||||
/>
|
||||
{sourceEndpointTypeList.map(([endpointType, count]) => {
|
||||
const iconModel = resolveEndpointTypeIconModel(endpointType);
|
||||
return (
|
||||
<FilterChip
|
||||
key={endpointType}
|
||||
active={activeSourceEndpointType === endpointType}
|
||||
label={endpointType}
|
||||
count={count}
|
||||
icon={iconModel ? <InlineBrandIcon model={iconModel} size={12} /> : <span style={{ fontSize: 10 }}>⚙</span>}
|
||||
onClick={() => setActiveSourceEndpointType(activeSourceEndpointType === endpointType ? null : endpointType)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{sourceEndpointTypeList.length === 0 ? (
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{tr('暂无接口能力数据')}</span>
|
||||
) : null}
|
||||
</FilterRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{filteredSourceRoutes.length === 0 ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
|
||||
{exactSourceRouteOptions.length === 0
|
||||
@@ -592,50 +869,131 @@ export default function ManualRoutePanel({
|
||||
: tr('没有匹配的来源模型。')}
|
||||
</div>
|
||||
) : (
|
||||
filteredSourceRoutes.map((route) => {
|
||||
const selected = sourcePickerSelection.includes(route.id);
|
||||
const label = renderRouteOptionLabel(route);
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '12px 14px',
|
||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
boxShadow: selected
|
||||
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
readOnly
|
||||
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
|
||||
{label !== route.modelPattern && (
|
||||
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
|
||||
<div
|
||||
className="source-route-picker-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 10,
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{filteredSourceRoutes.map((route) => {
|
||||
const selected = sourcePickerSelectionSet.has(route.id);
|
||||
const label = renderRouteOptionLabel(route);
|
||||
const brand = sourceRouteBrandById.get(route.id) || null;
|
||||
const endpointTypes = (sourceEndpointTypesByRouteId[route.id] || []).slice(0, 3);
|
||||
const siteNames = Array.from(new Set((route.siteNames || []).filter((siteName) => String(siteName || '').trim())));
|
||||
|
||||
return (
|
||||
<button
|
||||
key={route.id}
|
||||
type="button"
|
||||
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
|
||||
className="btn btn-ghost source-route-picker-card"
|
||||
style={{
|
||||
minHeight: 156,
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
textAlign: 'left',
|
||||
padding: 0,
|
||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
background: selected
|
||||
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
|
||||
: 'var(--color-bg-card)',
|
||||
boxShadow: selected
|
||||
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, transparent)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%', padding: '14px 15px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
readOnly
|
||||
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none', flexShrink: 0 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 22, height: 22, flexShrink: 0 }}>
|
||||
{brand ? <BrandGlyph brand={brand} size={18} fallbackText={label} /> : <InlineBrandIcon model={route.modelPattern} size={18} />}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0, flex: 1 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{label !== route.modelPattern ? (
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{route.modelPattern}
|
||||
</code>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{selected ? tr('已选中') : tr('可选择')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{siteNames.length} {tr('站点')}
|
||||
</span>
|
||||
{endpointTypes.map((endpointType) => (
|
||||
<span key={`${route.id}-${endpointType}`} className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{endpointType}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{siteNames.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{siteNames.slice(0, 3).map((siteName) => (
|
||||
<span
|
||||
key={`${route.id}-${siteName}`}
|
||||
className="badge badge-muted"
|
||||
style={{ fontSize: 10 }}
|
||||
>
|
||||
{siteName}
|
||||
</span>
|
||||
))}
|
||||
{siteNames.length > 3 ? (
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
+{siteNames.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
|
||||
{tr('当前未绑定站点信息')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{selected ? tr('已选中') : tr('可选择')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,6 +116,7 @@ function RouteCardInner({
|
||||
const routeIcon = resolveRouteIcon(route);
|
||||
const exactRoute = isRouteExactModel(route);
|
||||
const explicitGroupRoute = isExplicitGroupRoute(route);
|
||||
const explicitGroupSourceCount = Array.isArray(route.sourceRouteIds) ? route.sourceRouteIds.length : 0;
|
||||
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
|
||||
const channelManagementDisabled = explicitGroupRoute;
|
||||
const title = resolveRouteTitle(route);
|
||||
@@ -206,9 +207,20 @@ function RouteCardInner({
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<>
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{readOnlyRoute ? (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
|
||||
@@ -269,9 +281,20 @@ function RouteCardInner({
|
||||
{route.enabled ? tr('启用') : tr('禁用')}
|
||||
</button>
|
||||
)}
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
|
||||
<>
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{explicitGroupSourceCount} {tr('来源模型')}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{route.channelCount} {tr('通道')}
|
||||
</span>
|
||||
)}
|
||||
{readOnlyRoute && (
|
||||
<span className="badge badge-warning" style={{ fontSize: 10 }}>
|
||||
{tr('0 通道')}
|
||||
@@ -445,35 +468,29 @@ function RouteCardInner({
|
||||
|
||||
<AnimatedCollapseSection open={isGroupExpanded}>
|
||||
{explicitGroupRoute ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{group.channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
padding: '8px 12px',
|
||||
borderLeft: '2px solid var(--color-primary)',
|
||||
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
|
||||
background: 'rgba(79,70,229,0.02)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
|
||||
{channel.account?.username || `account-${channel.accountId}`}
|
||||
</span>
|
||||
<span className="badge badge-muted" style={{ fontSize: 10 }}>
|
||||
{channel.site?.name || 'unknown'}
|
||||
</span>
|
||||
{channel.sourceModel ? (
|
||||
<span className="badge badge-info" style={{ fontSize: 10 }}>
|
||||
{channel.sourceModel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{group.channels.map((channel) => {
|
||||
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
|
||||
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
|
||||
return (
|
||||
<SortableChannelRow
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
decisionCandidate={decisionMap.get(channel.id)}
|
||||
isExactRoute={exactRoute}
|
||||
loadingDecision={loadingDecision}
|
||||
isSavingPriority={!!savingPriority}
|
||||
readOnly
|
||||
tokenOptions={tokenOptions}
|
||||
activeTokenId={activeTokenId}
|
||||
isUpdatingToken={!!updatingChannel[channel.id]}
|
||||
onTokenDraftChange={onTokenDraftChange}
|
||||
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
|
||||
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||
{group.channels.map((channel) => {
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
|
||||
key={groupRoute.id}
|
||||
active={activeGroupFilter === groupRoute.id}
|
||||
label={groupRoute.title}
|
||||
count={groupRoute.channelCount}
|
||||
count={groupRoute.sourceRouteCount > 0 ? groupRoute.sourceRouteCount : groupRoute.channelCount}
|
||||
icon={
|
||||
groupRoute.icon.kind === 'brand' ? (
|
||||
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
|
||||
|
||||
@@ -16,6 +16,7 @@ export function SortableChannelRow({
|
||||
isExactRoute,
|
||||
loadingDecision,
|
||||
isSavingPriority,
|
||||
readOnly = false,
|
||||
tokenOptions,
|
||||
activeTokenId,
|
||||
isUpdatingToken,
|
||||
@@ -33,7 +34,7 @@ export function SortableChannelRow({
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: channel.id,
|
||||
disabled: isSavingPriority,
|
||||
disabled: isSavingPriority || readOnly,
|
||||
});
|
||||
|
||||
const rowStyle: CSSProperties = {
|
||||
@@ -42,7 +43,7 @@ export function SortableChannelRow({
|
||||
opacity: isDragging ? 0.72 : 1,
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto auto',
|
||||
gridTemplateColumns: readOnly ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) auto auto',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 12px',
|
||||
@@ -63,7 +64,7 @@ export function SortableChannelRow({
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={isSavingPriority}
|
||||
disabled={isSavingPriority || readOnly}
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
width: 22,
|
||||
@@ -72,9 +73,10 @@ export function SortableChannelRow({
|
||||
padding: 0,
|
||||
border: '1px solid var(--color-border-light)',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: isSavingPriority ? 'not-allowed' : 'grab',
|
||||
cursor: isSavingPriority || readOnly ? 'not-allowed' : 'grab',
|
||||
opacity: readOnly ? 0.65 : 1,
|
||||
}}
|
||||
data-tooltip="拖拽调整优先级"
|
||||
data-tooltip={readOnly ? '来源群组继承通道优先级,不能在这里拖动' : '拖拽调整优先级'}
|
||||
aria-label="拖拽调整优先级"
|
||||
>
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 12 12" aria-hidden>
|
||||
@@ -197,46 +199,50 @@ export function SortableChannelRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{
|
||||
value: '0',
|
||||
label: '跟随账号默认',
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(activeTokenId || 0)}
|
||||
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
|
||||
disabled={isUpdatingToken}
|
||||
options={[
|
||||
{
|
||||
value: '0',
|
||||
label: '跟随账号默认',
|
||||
description: tokenBinding.followOptionDescription,
|
||||
},
|
||||
...tokenOptions.map((token) => ({
|
||||
value: String(token.id),
|
||||
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
|
||||
description: buildFixedTokenOptionDescription(token),
|
||||
})),
|
||||
]}
|
||||
placeholder="选择令牌绑定方式"
|
||||
/>
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
|
||||
{tokenBinding.helperText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSaveToken}
|
||||
disabled={isUpdatingToken}
|
||||
className="btn btn-link btn-link-info"
|
||||
>
|
||||
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
<button
|
||||
onClick={onDeleteChannel}
|
||||
className="btn btn-link btn-link-danger"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ export type SortableChannelRowProps = {
|
||||
isExactRoute: boolean;
|
||||
loadingDecision: boolean;
|
||||
isSavingPriority: boolean;
|
||||
readOnly?: boolean;
|
||||
tokenOptions: RouteTokenOption[];
|
||||
activeTokenId: number;
|
||||
isUpdatingToken: boolean;
|
||||
@@ -165,4 +166,5 @@ export type GroupRouteItem = {
|
||||
brand: BrandInfo | null;
|
||||
modelPattern: string;
|
||||
channelCount: number;
|
||||
sourceRouteCount: number;
|
||||
};
|
||||
|
||||
@@ -48,6 +48,23 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta
|
||||
));
|
||||
}
|
||||
|
||||
function findButtonByClassAndText(root: ReactTestInstance, className: string, text: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& String(node.props.className || '').includes(className)
|
||||
&& collectText(node).includes(text)
|
||||
));
|
||||
}
|
||||
|
||||
function findButtonByAriaLabel(root: ReactTestInstance, label: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props['aria-label'] === 'string'
|
||||
&& node.props['aria-label'] === label
|
||||
));
|
||||
}
|
||||
|
||||
function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance {
|
||||
return root.find((node) => (
|
||||
node.type === 'input'
|
||||
@@ -865,6 +882,195 @@ describe('TokenRoutes grouped source models', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the source picker like the route page with brand, site, ability filters and a card grid', async () => {
|
||||
getBrandMock.mockImplementation((modelName: string) => {
|
||||
const model = String(modelName);
|
||||
if (model.includes('gpt')) {
|
||||
return { name: 'OpenAI', icon: 'openai', color: 'linear-gradient(135deg,#111,#555)' };
|
||||
}
|
||||
if (model.includes('claude')) {
|
||||
return { name: 'Anthropic', icon: 'anthropic', color: 'linear-gradient(135deg,#d97706,#f59e0b)' };
|
||||
}
|
||||
if (model.includes('gemini')) {
|
||||
return { name: 'Gemini', icon: 'gemini', color: 'linear-gradient(135deg,#2563eb,#7c3aed)' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 11, modelPattern: 'gpt-5.4', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 3, enabledChannelCount: 3, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 2, enabledChannelCount: 2, siteNames: ['Alpha'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 13, modelPattern: 'gemini-2.5-pro', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getModelTokenCandidates.mockResolvedValue({
|
||||
models: {},
|
||||
endpointTypesByModel: {
|
||||
'gpt-5.4': ['openai'],
|
||||
'claude-sonnet-4-5': ['anthropic'],
|
||||
'gemini-2.5-pro': ['gemini'],
|
||||
},
|
||||
});
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, '新建群组').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, '选择来源模型').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const pickerGrid = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('source-route-picker-grid')
|
||||
));
|
||||
expect(String(pickerGrid.props.style?.display || '')).toBe('grid');
|
||||
expect(String(pickerGrid.props.style?.gridTemplateColumns || '')).toContain('repeat(');
|
||||
|
||||
expect(findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI')).toBeTruthy();
|
||||
expect(findButtonByClassAndText(root.root, 'filter-chip', 'Wong')).toBeTruthy();
|
||||
expect(findButtonByClassAndText(root.root, 'filter-chip', 'gemini')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(pickerGrid)).toContain('gpt-5.4');
|
||||
expect(collectText(pickerGrid)).toContain('gemini-2.5-pro');
|
||||
expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5');
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'Wong').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(pickerGrid)).toContain('OpenAI');
|
||||
expect(collectText(pickerGrid)).toContain('gpt-5.4');
|
||||
expect(collectText(pickerGrid)).not.toContain('claude-sonnet-4-5');
|
||||
expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro');
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'OpenAI').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByClassAndText(root.root, 'filter-chip', 'anthropic').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(collectText(pickerGrid)).toContain('Anthropic');
|
||||
expect(collectText(pickerGrid)).toContain('claude-sonnet-4-5');
|
||||
expect(collectText(pickerGrid)).not.toContain('gpt-5.4');
|
||||
expect(collectText(pickerGrid)).not.toContain('gemini-2.5-pro');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows explicit-group source counts instead of aggregated channel counts in the route list and filter chips', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 11, modelPattern: 'deepseek-chat', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 40, enabledChannelCount: 40, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 12, modelPattern: 'deepseek-reasoner', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 55, enabledChannelCount: 55, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 21, modelPattern: 'deepseekv1', displayName: 'deepseekv1',
|
||||
displayIcon: '', modelMapping: null, enabled: true,
|
||||
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
|
||||
channelCount: 95, enabledChannelCount: 95, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 101, accountId: 1, tokenId: 1, sourceModel: 'deepseek-chat',
|
||||
priority: 0, weight: 1, enabled: true, manualOverride: false,
|
||||
successCount: 0, failCount: 0,
|
||||
account: { username: 'user-a' }, site: { name: 'Wong' }, token: null,
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const filterSummary = root.root.find((node) =>
|
||||
node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
|
||||
);
|
||||
await act(async () => {
|
||||
filterSummary.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const groupChip = findButtonByClassAndText(root.root, 'filter-chip', 'deepseekv1');
|
||||
expect(collectText(groupChip)).toContain('2');
|
||||
expect(collectText(groupChip)).not.toContain('95');
|
||||
|
||||
const routeCard = root.root.find((node) =>
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
&& collectText(node).includes('deepseekv1'),
|
||||
);
|
||||
expect(collectText(routeCard).replace(/\s+/g, '')).toContain('2来源模型');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
@@ -1169,4 +1375,80 @@ describe('TokenRoutes grouped source models', () => {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the standard channel row presentation for explicit-group details in read-only mode', async () => {
|
||||
apiMock.getRoutesSummary.mockResolvedValue([
|
||||
{
|
||||
id: 11, modelPattern: 'claude-haiku-4-5-20251001', displayName: null,
|
||||
displayIcon: null, modelMapping: null, enabled: true,
|
||||
routeMode: 'pattern', sourceRouteIds: [],
|
||||
channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
{
|
||||
id: 21, modelPattern: 'claude-haiku-proxy', displayName: 'claude-haiku-proxy',
|
||||
displayIcon: '', modelMapping: null, enabled: true,
|
||||
routeMode: 'explicit_group', sourceRouteIds: [11],
|
||||
channelCount: 6, enabledChannelCount: 6, siteNames: ['Wong'],
|
||||
decisionSnapshot: null, decisionRefreshedAt: null,
|
||||
},
|
||||
]);
|
||||
apiMock.getRouteChannels.mockResolvedValue([
|
||||
{
|
||||
id: 101,
|
||||
routeId: 11,
|
||||
accountId: 301,
|
||||
tokenId: 401,
|
||||
sourceModel: 'claude-haiku-4-5-20251001',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
successCount: 6,
|
||||
failCount: 1,
|
||||
account: { username: 'linuxdo_131936' },
|
||||
site: { name: 'Wong' },
|
||||
token: { id: 401, name: 'token-a', accountId: 301, enabled: true, isDefault: true },
|
||||
},
|
||||
]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/routes']}>
|
||||
<ToastProvider>
|
||||
<TokenRoutes />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandBtn = root.root.find((node) =>
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||
&& collectText(node).includes('claude-haiku-proxy'),
|
||||
);
|
||||
await act(async () => {
|
||||
expandBtn.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
findButtonByText(root.root, 'claude-haiku-4-5-20251001').props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const expandedText = collectText(root.root);
|
||||
expect(expandedText).toContain('P0');
|
||||
expect(expandedText).toContain('当前生效:token-a');
|
||||
expect(expandedText).toContain('选中概率');
|
||||
expect(findButtonByAriaLabel(root.root, '拖拽调整优先级').props.disabled).toBe(true);
|
||||
expect(expandedText).not.toContain('保存');
|
||||
expect(expandedText).not.toContain('移除');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user