mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-16 09:33:05 +00:00
feat: enhance text protocol conversion and advanced custom routing (#5825)
* refactor: consolidate relay protocol converters * refactor relayconvert text converters * feat: refine relay converters and advanced custom routing * refactor: enhance logging and add thought signature handling for Gemini requests * refactor: enhance channel cache and pricing endpoint handling for advanced custom models * feat: preserve billing usage semantics * feat: add protocol-aware billing usage * Delete useless files * chore: update action versions in workflow files * chore: update Docker action versions in workflow files * fix: harden billing usage settlement and hot-path route matching - estimate Gemini completion tokens locally when billable usageMetadata is prompt-only but output content was received (e.g. client aborts the stream before the final chunk), and rebuild the attached billing_usage as estimated so settlement does not bill zero output tokens - guard NewClaudeMessagesBillingUsage against all-zero ClaudeUsage, matching the OpenAI/Gemini constructors, so a zero billing_usage cannot override a non-zero top-level usage during settlement - cache compiled advanced-custom route model regexes; they run on the request hot path and were recompiled per request - move the effectiveBillingUsage remap to PostTextConsumeQuota only, and document that calculateTextQuotaSummary expects remapped usage - document the updatePricingLock -> channelSyncLock lock ordering that InitChannelCache/CacheUpdateChannel rely on, and the aux-struct pitfall in GeminiChatResponse.UnmarshalJSON
This commit is contained in:
@@ -36,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
|
||||||
ref: ${{ github.event.inputs.tag || github.ref }}
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
@@ -59,23 +59,23 @@ jobs:
|
|||||||
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
|
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (labels)
|
- name: Extract metadata (labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: calciumion/new-api
|
images: calciumion/new-api
|
||||||
|
|
||||||
- name: Build & push
|
- name: Build & push
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
@@ -90,7 +90,7 @@ jobs:
|
|||||||
sbom: true
|
sbom: true
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@v3
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Sign image with cosign
|
- name: Sign image with cosign
|
||||||
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
||||||
@@ -117,7 +117,7 @@ jobs:
|
|||||||
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
|
run: echo "TAG=${{ needs.build_single_arch.outputs.tag }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
ref: ${{ inputs.branch }}
|
ref: ${{ inputs.branch }}
|
||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out branch
|
- name: Check out branch
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
ref: ${{ needs.prepare.outputs.sha }}
|
ref: ${{ needs.prepare.outputs.sha }}
|
||||||
@@ -79,24 +79,24 @@ jobs:
|
|||||||
echo "Publishing version: ${{ needs.prepare.outputs.version }} for ${{ matrix.arch }}"
|
echo "Publishing version: ${{ needs.prepare.outputs.version }} for ${{ matrix.arch }}"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata (labels)
|
- name: Extract metadata (labels)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
calciumion/new-api
|
calciumion/new-api
|
||||||
|
|
||||||
- name: Build & push single-arch
|
- name: Build & push single-arch
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
@@ -111,7 +111,7 @@ jobs:
|
|||||||
sbom: true
|
sbom: true
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Sign image with cosign
|
- name: Sign image with cosign
|
||||||
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
|
||||||
@@ -133,7 +133,7 @@ jobs:
|
|||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -153,7 +153,7 @@ jobs:
|
|||||||
calciumion/new-api:${{ needs.prepare.outputs.version }}-arm64
|
calciumion/new-api:${{ needs.prepare.outputs.version }}-arm64
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Sign manifests with cosign
|
- name: Sign manifests with cosign
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -22,22 +22,22 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.25.1'
|
go-version: '>=1.25.1'
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# - name: Upload artifacts (macOS)
|
# - name: Upload artifacts (macOS)
|
||||||
# if: runner.os == 'macOS'
|
# if: runner.os == 'macOS'
|
||||||
# uses: actions/upload-artifact@v4
|
# uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
# with:
|
# with:
|
||||||
# name: macos-build
|
# name: macos-build
|
||||||
# path: |
|
# path: |
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload artifacts (Windows)
|
- name: Upload artifacts (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: windows-build
|
name: windows-build
|
||||||
path: |
|
path: |
|
||||||
@@ -130,10 +130,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
|
||||||
- name: Upload to Release
|
- name: Upload to Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
windows-build/*
|
windows-build/*
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
pr-quality:
|
pr-quality:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: peakoss/anti-slop@v0.2.1
|
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
|
||||||
with:
|
with:
|
||||||
max-failures: 4
|
max-failures: 4
|
||||||
require-description: true
|
require-description: true
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Determine Version
|
- name: Determine Version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(git describe --tags)
|
VERSION=$(git describe --tags)
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend (default)
|
- name: Build Frontend (default)
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.25.1'
|
go-version: '>=1.25.1'
|
||||||
- name: Build Backend (amd64)
|
- name: Build Backend (amd64)
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
run: sha256sum new-api-* > checksums-linux.txt
|
run: sha256sum new-api-* > checksums-linux.txt
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
@@ -78,14 +78,14 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Determine Version
|
- name: Determine Version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(git describe --tags)
|
VERSION=$(git describe --tags)
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend (default)
|
- name: Build Frontend (default)
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.25.1'
|
go-version: '>=1.25.1'
|
||||||
- name: Build Backend
|
- name: Build Backend
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
run: shasum -a 256 new-api-macos-* > checksums-macos.txt
|
run: shasum -a 256 new-api-macos-* > checksums-macos.txt
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
@@ -136,14 +136,14 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Determine Version
|
- name: Determine Version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(git describe --tags)
|
VERSION=$(git describe --tags)
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: latest
|
||||||
- name: Build Frontend (default)
|
- name: Build Frontend (default)
|
||||||
@@ -165,7 +165,7 @@ jobs:
|
|||||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||||
cd ../..
|
cd ../..
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
|
||||||
with:
|
with:
|
||||||
go-version: '>=1.25.1'
|
go-version: '>=1.25.1'
|
||||||
- name: Build Backend
|
- name: Build Backend
|
||||||
@@ -176,7 +176,7 @@ jobs:
|
|||||||
run: sha256sum new-api-*.exe > checksums-windows.txt
|
run: sha256sum new-api-*.exe > checksums-windows.txt
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
|
|||||||
@@ -131,7 +131,17 @@ func withSelfUseModeDisabled(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeListModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]struct{} {
|
func withSelfUseModeEnabled(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
original := operation_setting.SelfUseModeEnabled
|
||||||
|
operation_setting.SelfUseModeEnabled = true
|
||||||
|
t.Cleanup(func() {
|
||||||
|
operation_setting.SelfUseModeEnabled = original
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeListModelsPayload(t *testing.T, recorder *httptest.ResponseRecorder) listModelsResponse {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, recorder.Code)
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
@@ -139,7 +149,13 @@ func decodeListModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder)
|
|||||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
|
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||||
require.True(t, payload.Success)
|
require.True(t, payload.Success)
|
||||||
require.Equal(t, "list", payload.Object)
|
require.Equal(t, "list", payload.Object)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeListModelsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]struct{} {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
payload := decodeListModelsPayload(t, recorder)
|
||||||
ids := make(map[string]struct{}, len(payload.Data))
|
ids := make(map[string]struct{}, len(payload.Data))
|
||||||
for _, item := range payload.Data {
|
for _, item := range payload.Data {
|
||||||
ids[item.Id] = struct{}{}
|
ids[item.Id] = struct{}{}
|
||||||
@@ -255,6 +271,77 @@ func TestListModelsIncludesTieredBillingModel(t *testing.T) {
|
|||||||
require.Empty(t, missingExprPricing.BillingExpr)
|
require.Empty(t, missingExprPricing.BillingExpr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListModelsUsesAdvancedCustomEndpointTypesFromPricingCache(t *testing.T) {
|
||||||
|
withSelfUseModeEnabled(t)
|
||||||
|
db := setupModelListControllerTestDB(t)
|
||||||
|
|
||||||
|
originalMemoryCacheEnabled := common.MemoryCacheEnabled
|
||||||
|
common.MemoryCacheEnabled = true
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.MemoryCacheEnabled = originalMemoryCacheEnabled
|
||||||
|
model.InvalidatePricingCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, db.Create(&model.User{
|
||||||
|
Id: 1003,
|
||||||
|
Username: "advanced-custom-model-list-user",
|
||||||
|
Password: "password",
|
||||||
|
Group: "default",
|
||||||
|
Status: common.UserStatusEnabled,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
channel := &model.Channel{
|
||||||
|
Id: 701,
|
||||||
|
Type: constant.ChannelTypeAdvancedCustom,
|
||||||
|
Key: "advanced-custom-key",
|
||||||
|
Status: common.ChannelStatusEnabled,
|
||||||
|
Name: "advanced-custom-channel",
|
||||||
|
Group: "default",
|
||||||
|
Models: "gemini-3.5-flash",
|
||||||
|
}
|
||||||
|
channel.SetOtherSettings(dto.ChannelOtherSettings{
|
||||||
|
AdvancedCustom: &dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, db.Create(channel).Error)
|
||||||
|
require.NoError(t, db.Create(&model.Ability{
|
||||||
|
Group: "default",
|
||||||
|
Model: "gemini-3.5-flash",
|
||||||
|
ChannelId: 701,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
model.InitChannelCache()
|
||||||
|
model.GetPricing()
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||||
|
ctx.Set("id", 1003)
|
||||||
|
|
||||||
|
ListModels(ctx, constant.ChannelTypeOpenAI)
|
||||||
|
|
||||||
|
payload := decodeListModelsPayload(t, recorder)
|
||||||
|
require.Len(t, payload.Data, 1)
|
||||||
|
require.Equal(t, "gemini-3.5-flash", payload.Data[0].Id)
|
||||||
|
require.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
}, payload.Data[0].SupportedEndpointTypes)
|
||||||
|
}
|
||||||
|
|
||||||
func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
|
func TestListModelsTokenLimitIncludesTieredBillingModel(t *testing.T) {
|
||||||
withSelfUseModeDisabled(t)
|
withSelfUseModeDisabled(t)
|
||||||
withTieredBillingConfig(t, map[string]string{
|
withTieredBillingConfig(t, map[string]string{
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
const (
|
||||||
|
BillingUsageSourceClaudeMessages = "claude_messages"
|
||||||
|
BillingUsageSourceGeminiChat = "gemini_chat"
|
||||||
|
BillingUsageSourceOAIChat = "oai_chat"
|
||||||
|
BillingUsageSourceOAIResponses = "oai_responses"
|
||||||
|
|
||||||
|
BillingUsageSemanticAnthropic = "anthropic"
|
||||||
|
BillingUsageSemanticGemini = "gemini"
|
||||||
|
BillingUsageSemanticOpenAI = "openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BillingUsage struct {
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Semantic string `json:"semantic,omitempty"`
|
||||||
|
Estimated bool `json:"estimated,omitempty"`
|
||||||
|
OpenAIUsage *Usage `json:"openai_usage,omitempty"`
|
||||||
|
ClaudeUsage *ClaudeUsage `json:"claude_usage,omitempty"`
|
||||||
|
GeminiUsageMetadata *GeminiUsageMetadata `json:"gemini_usage_metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClaudeMessagesBillingUsage(usage *ClaudeUsage) *BillingUsage {
|
||||||
|
if !HasClaudeUsageTokens(usage) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &BillingUsage{
|
||||||
|
Source: BillingUsageSourceClaudeMessages,
|
||||||
|
Semantic: BillingUsageSemanticAnthropic,
|
||||||
|
ClaudeUsage: cloneClaudeUsage(usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasClaudeUsageTokens mirrors HasOpenAIUsageTokens/HasGeminiUsageMetadataTokens:
|
||||||
|
// an all-zero ClaudeUsage must not become a BillingUsage, otherwise it would take
|
||||||
|
// precedence during settlement and zero out a non-zero top-level usage.
|
||||||
|
func HasClaudeUsageTokens(usage *ClaudeUsage) bool {
|
||||||
|
if usage == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if usage.InputTokens != 0 ||
|
||||||
|
usage.OutputTokens != 0 ||
|
||||||
|
usage.CacheCreationInputTokens != 0 ||
|
||||||
|
usage.CacheReadInputTokens != 0 ||
|
||||||
|
usage.ClaudeCacheCreation5mTokens != 0 ||
|
||||||
|
usage.ClaudeCacheCreation1hTokens != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if usage.CacheCreation != nil &&
|
||||||
|
(usage.CacheCreation.Ephemeral5mInputTokens != 0 || usage.CacheCreation.Ephemeral1hInputTokens != 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAIChatBillingUsage(usage *Usage) *BillingUsage {
|
||||||
|
return newOpenAIBillingUsage(BillingUsageSourceOAIChat, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAIResponsesBillingUsage(usage *Usage) *BillingUsage {
|
||||||
|
return newOpenAIBillingUsage(BillingUsageSourceOAIResponses, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOpenAIBillingUsage(source string, usage *Usage) *BillingUsage {
|
||||||
|
if !HasOpenAIUsageTokens(usage) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &BillingUsage{
|
||||||
|
Source: source,
|
||||||
|
Semantic: BillingUsageSemanticOpenAI,
|
||||||
|
OpenAIUsage: cloneOpenAIUsage(usage),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasOpenAIUsageTokens(usage *Usage) bool {
|
||||||
|
if usage == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if usage.PromptTokens != 0 ||
|
||||||
|
usage.CompletionTokens != 0 ||
|
||||||
|
usage.TotalTokens != 0 ||
|
||||||
|
usage.InputTokens != 0 ||
|
||||||
|
usage.OutputTokens != 0 ||
|
||||||
|
usage.PromptCacheHitTokens != 0 ||
|
||||||
|
usage.ClaudeCacheCreation5mTokens != 0 ||
|
||||||
|
usage.ClaudeCacheCreation1hTokens != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if usage.PromptTokensDetails.CachedTokens != 0 ||
|
||||||
|
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||||
|
usage.PromptTokensDetails.TextTokens != 0 ||
|
||||||
|
usage.PromptTokensDetails.ImageTokens != 0 ||
|
||||||
|
usage.PromptTokensDetails.AudioTokens != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if usage.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||||
|
usage.CompletionTokenDetails.TextTokens != 0 ||
|
||||||
|
usage.CompletionTokenDetails.ImageTokens != 0 ||
|
||||||
|
usage.CompletionTokenDetails.AudioTokens != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return usage.InputTokensDetails != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGeminiChatBillingUsage(metadata *GeminiUsageMetadata) *BillingUsage {
|
||||||
|
return newGeminiChatBillingUsage(metadata, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
totalTokens := usage.TotalTokens
|
||||||
|
if totalTokens == 0 {
|
||||||
|
totalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
}
|
||||||
|
return newGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: usage.PromptTokens,
|
||||||
|
CandidatesTokenCount: usage.CompletionTokens,
|
||||||
|
TotalTokenCount: totalTokens,
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
|
||||||
|
if !HasGeminiUsageMetadataTokens(metadata) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
usageMetadata := cloneGeminiUsageMetadata(*metadata)
|
||||||
|
return &BillingUsage{
|
||||||
|
Source: BillingUsageSourceGeminiChat,
|
||||||
|
Semantic: BillingUsageSemanticGemini,
|
||||||
|
Estimated: estimated,
|
||||||
|
GeminiUsageMetadata: &usageMetadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *usage
|
||||||
|
clone.OpenAIUsage = cloneOpenAIUsage(usage.OpenAIUsage)
|
||||||
|
clone.ClaudeUsage = cloneClaudeUsage(usage.ClaudeUsage)
|
||||||
|
if usage.GeminiUsageMetadata != nil {
|
||||||
|
metadata := cloneGeminiUsageMetadata(*usage.GeminiUsageMetadata)
|
||||||
|
clone.GeminiUsageMetadata = &metadata
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOpenAIUsage(usage *Usage) *Usage {
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *usage
|
||||||
|
clone.BillingUsage = nil
|
||||||
|
if usage.InputTokensDetails != nil {
|
||||||
|
inputTokensDetails := *usage.InputTokensDetails
|
||||||
|
clone.InputTokensDetails = &inputTokensDetails
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneClaudeUsage(usage *ClaudeUsage) *ClaudeUsage {
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *usage
|
||||||
|
clone.BillingUsage = nil
|
||||||
|
if usage.CacheCreation != nil {
|
||||||
|
cacheCreation := *usage.CacheCreation
|
||||||
|
clone.CacheCreation = &cacheCreation
|
||||||
|
}
|
||||||
|
if usage.ServerToolUse != nil {
|
||||||
|
serverToolUse := *usage.ServerToolUse
|
||||||
|
clone.ServerToolUse = &serverToolUse
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneGeminiUsageMetadata(metadata GeminiUsageMetadata) GeminiUsageMetadata {
|
||||||
|
metadata.PromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.PromptTokensDetails...)
|
||||||
|
metadata.ToolUsePromptTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.ToolUsePromptTokensDetails...)
|
||||||
|
metadata.CandidatesTokensDetails = append([]GeminiPromptTokensDetails{}, metadata.CandidatesTokensDetails...)
|
||||||
|
metadata.BillingUsage = nil
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasGeminiUsageMetadataTokens(metadata *GeminiUsageMetadata) bool {
|
||||||
|
if metadata == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if metadata.PromptTokenCount != 0 ||
|
||||||
|
metadata.ToolUsePromptTokenCount != 0 ||
|
||||||
|
metadata.CandidatesTokenCount != 0 ||
|
||||||
|
metadata.TotalTokenCount != 0 ||
|
||||||
|
metadata.ThoughtsTokenCount != 0 ||
|
||||||
|
metadata.CachedContentTokenCount != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.PromptTokensDetails {
|
||||||
|
if detail.TokenCount != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.ToolUsePromptTokensDetails {
|
||||||
|
if detail.TokenCount != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.CandidatesTokensDetails {
|
||||||
|
if detail.TokenCount != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGeminiChatBillingUsageRequiresTokenContent(t *testing.T) {
|
||||||
|
require.Nil(t, NewGeminiChatBillingUsage(nil))
|
||||||
|
require.Nil(t, NewGeminiChatBillingUsage(&GeminiUsageMetadata{}))
|
||||||
|
|
||||||
|
billingUsage := NewGeminiChatBillingUsage(&GeminiUsageMetadata{PromptTokenCount: 1})
|
||||||
|
require.NotNil(t, billingUsage)
|
||||||
|
require.NotNil(t, billingUsage.GeminiUsageMetadata)
|
||||||
|
assert.Equal(t, BillingUsageSourceGeminiChat, billingUsage.Source)
|
||||||
|
assert.Equal(t, BillingUsageSemanticGemini, billingUsage.Semantic)
|
||||||
|
assert.False(t, billingUsage.Estimated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewClaudeMessagesBillingUsageRequiresTokenContent(t *testing.T) {
|
||||||
|
require.Nil(t, NewClaudeMessagesBillingUsage(nil))
|
||||||
|
require.Nil(t, NewClaudeMessagesBillingUsage(&ClaudeUsage{}))
|
||||||
|
require.Nil(t, NewClaudeMessagesBillingUsage(&ClaudeUsage{CacheCreation: &ClaudeCacheCreationUsage{}}))
|
||||||
|
|
||||||
|
billingUsage := NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 1})
|
||||||
|
require.NotNil(t, billingUsage)
|
||||||
|
require.NotNil(t, billingUsage.ClaudeUsage)
|
||||||
|
assert.Equal(t, BillingUsageSourceClaudeMessages, billingUsage.Source)
|
||||||
|
assert.Equal(t, BillingUsageSemanticAnthropic, billingUsage.Semantic)
|
||||||
|
|
||||||
|
cacheOnly := NewClaudeMessagesBillingUsage(&ClaudeUsage{
|
||||||
|
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral5mInputTokens: 4},
|
||||||
|
})
|
||||||
|
require.NotNil(t, cacheOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOpenAIChatBillingUsageRequiresTokenContent(t *testing.T) {
|
||||||
|
require.Nil(t, NewOpenAIChatBillingUsage(nil))
|
||||||
|
require.Nil(t, NewOpenAIChatBillingUsage(&Usage{}))
|
||||||
|
|
||||||
|
billingUsage := NewOpenAIChatBillingUsage(&Usage{PromptTokens: 1})
|
||||||
|
require.NotNil(t, billingUsage)
|
||||||
|
require.NotNil(t, billingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, BillingUsageSourceOAIChat, billingUsage.Source)
|
||||||
|
assert.Equal(t, BillingUsageSemanticOpenAI, billingUsage.Semantic)
|
||||||
|
assert.Equal(t, 1, billingUsage.OpenAIUsage.PromptTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
|
||||||
|
billingUsage := NewEstimatedGeminiChatBillingUsage(&Usage{
|
||||||
|
PromptTokens: 11,
|
||||||
|
CompletionTokens: 7,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NotNil(t, billingUsage)
|
||||||
|
require.NotNil(t, billingUsage.GeminiUsageMetadata)
|
||||||
|
assert.True(t, billingUsage.Estimated)
|
||||||
|
assert.Equal(t, 11, billingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
assert.Equal(t, 7, billingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
|
||||||
|
billingUsage := &BillingUsage{
|
||||||
|
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
|
||||||
|
ClaudeUsage: &ClaudeUsage{InputTokens: 2, BillingUsage: NewOpenAIChatBillingUsage(&Usage{PromptTokens: 8})},
|
||||||
|
GeminiUsageMetadata: &GeminiUsageMetadata{PromptTokenCount: 3, BillingUsage: NewOpenAIChatBillingUsage(&Usage{PromptTokens: 7})},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := common.Marshal(billingUsage)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Contains(t, string(data), `"openai_usage"`)
|
||||||
|
assert.Contains(t, string(data), `"claude_usage"`)
|
||||||
|
assert.Contains(t, string(data), `"gemini_usage_metadata"`)
|
||||||
|
assert.NotContains(t, string(data), `"usage":`)
|
||||||
|
assert.NotContains(t, string(data), `"usage_metadata"`)
|
||||||
|
|
||||||
|
clone := CloneBillingUsage(billingUsage)
|
||||||
|
require.NotNil(t, clone.OpenAIUsage)
|
||||||
|
require.NotNil(t, clone.ClaudeUsage)
|
||||||
|
require.NotNil(t, clone.GeminiUsageMetadata)
|
||||||
|
assert.Nil(t, clone.OpenAIUsage.BillingUsage)
|
||||||
|
assert.Nil(t, clone.ClaudeUsage.BillingUsage)
|
||||||
|
assert.Nil(t, clone.GeminiUsageMetadata.BillingUsage)
|
||||||
|
}
|
||||||
+244
-27
@@ -3,7 +3,11 @@ package dto
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChannelSettings struct {
|
type ChannelSettings struct {
|
||||||
@@ -59,13 +63,14 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AdvancedCustomConverterNone = "none"
|
advancedCustomConverterNone = "none"
|
||||||
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions"
|
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages"
|
advancedCustomConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses"
|
advancedCustomConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
|
||||||
AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions = "openai_responses_to_openai_chat_completions"
|
advancedCustomConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
|
||||||
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions"
|
advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content"
|
advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
|
||||||
|
advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -82,6 +87,7 @@ type AdvancedCustomRoute struct {
|
|||||||
IncomingPath string `json:"incoming_path,omitempty"`
|
IncomingPath string `json:"incoming_path,omitempty"`
|
||||||
UpstreamPath string `json:"upstream_path,omitempty"`
|
UpstreamPath string `json:"upstream_path,omitempty"`
|
||||||
Converter string `json:"converter,omitempty"`
|
Converter string `json:"converter,omitempty"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
|
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +97,20 @@ type AdvancedCustomRouteAuth struct {
|
|||||||
Value string `json:"value,omitempty"`
|
Value string `json:"value,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const advancedCustomModelPlaceholder = "{model}"
|
const (
|
||||||
|
advancedCustomModelPlaceholder = "{model}"
|
||||||
|
advancedCustomModelRegexPrefix = "re:"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
advancedCustomEndpointPathOpenAIChat = "/v1/chat/completions"
|
||||||
|
advancedCustomEndpointPathOpenAIResponses = "/v1/responses"
|
||||||
|
advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact"
|
||||||
|
advancedCustomEndpointPathClaudeMessages = "/v1/messages"
|
||||||
|
advancedCustomEndpointPathJinaRerank = "/v1/rerank"
|
||||||
|
advancedCustomEndpointPathImageGeneration = "/v1/images/generations"
|
||||||
|
advancedCustomEndpointPathEmbeddings = "/v1/embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
// MatchPath returns the first route whose IncomingPath matches requestPath.
|
// MatchPath returns the first route whose IncomingPath matches requestPath.
|
||||||
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
|
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
|
||||||
@@ -108,12 +127,133 @@ func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRout
|
|||||||
return AdvancedCustomRoute{}, false
|
return AdvancedCustomRoute{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MatchPathForModel returns the first route whose IncomingPath and Models match.
|
||||||
|
// An empty Models list is a catch-all fallback for that incoming path.
|
||||||
|
func (c *AdvancedCustomConfig) MatchPathForModel(requestPath string, model string) (AdvancedCustomRoute, bool) {
|
||||||
|
if c == nil {
|
||||||
|
return AdvancedCustomRoute{}, false
|
||||||
|
}
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
for _, route := range c.Routes {
|
||||||
|
if matchAdvancedCustomIncomingPath(strings.TrimSpace(route.IncomingPath), requestPath) &&
|
||||||
|
matchAdvancedCustomRouteModel(route.Models, model) {
|
||||||
|
return route, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AdvancedCustomRoute{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// SupportsPath reports whether any route matches requestPath.
|
// SupportsPath reports whether any route matches requestPath.
|
||||||
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
|
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
|
||||||
_, ok := c.MatchPath(requestPath)
|
_, ok := c.MatchPath(requestPath)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupportsPathForModel reports whether any route matches requestPath and model.
|
||||||
|
func (c *AdvancedCustomConfig) SupportsPathForModel(requestPath string, model string) bool {
|
||||||
|
_, ok := c.MatchPathForModel(requestPath, model)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AdvancedCustomConfig) SupportedEndpointTypesForModel(model string) []constant.EndpointType {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
endpoints := make([]constant.EndpointType, 0, len(c.Routes))
|
||||||
|
seen := make(map[constant.EndpointType]struct{}, len(c.Routes))
|
||||||
|
for _, route := range c.Routes {
|
||||||
|
if !matchAdvancedCustomRouteModel(route.Models, model) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
endpointType, ok := advancedCustomEndpointTypeFromIncomingPath(strings.TrimSpace(route.IncomingPath))
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[endpointType]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[endpointType] = struct{}{}
|
||||||
|
endpoints = append(endpoints, endpointType)
|
||||||
|
}
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (constant.EndpointType, bool) {
|
||||||
|
switch incomingPath {
|
||||||
|
case advancedCustomEndpointPathOpenAIChat:
|
||||||
|
return constant.EndpointTypeOpenAI, true
|
||||||
|
case advancedCustomEndpointPathOpenAIResponses:
|
||||||
|
return constant.EndpointTypeOpenAIResponse, true
|
||||||
|
case advancedCustomEndpointPathOpenAIResponsesCompact:
|
||||||
|
return constant.EndpointTypeOpenAIResponseCompact, true
|
||||||
|
case advancedCustomEndpointPathClaudeMessages:
|
||||||
|
return constant.EndpointTypeAnthropic, true
|
||||||
|
case advancedCustomEndpointPathJinaRerank:
|
||||||
|
return constant.EndpointTypeJinaRerank, true
|
||||||
|
case advancedCustomEndpointPathImageGeneration:
|
||||||
|
return constant.EndpointTypeImageGeneration, true
|
||||||
|
case advancedCustomEndpointPathEmbeddings:
|
||||||
|
return constant.EndpointTypeEmbeddings, true
|
||||||
|
default:
|
||||||
|
if isAdvancedCustomGeminiIncomingPath(incomingPath) {
|
||||||
|
return constant.EndpointTypeGemini, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdvancedCustomGeminiIncomingPath(incomingPath string) bool {
|
||||||
|
if !strings.HasPrefix(incomingPath, "/v1beta/models/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAdvancedCustomRouteModel(models []string, model string) bool {
|
||||||
|
normalizedModels := normalizeAdvancedCustomRouteModels(models)
|
||||||
|
if len(normalizedModels) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, allowedModel := range normalizedModels {
|
||||||
|
if matchAdvancedCustomRouteModelRule(allowedModel, model) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// advancedCustomModelRegexCache caches compiled route model patterns. Route model
|
||||||
|
// matching runs on the request hot path (distributor affinity, ability filtering,
|
||||||
|
// channel cache filtering, adaptor resolve), so patterns must not be recompiled per
|
||||||
|
// request. Invalid patterns are cached as nil to avoid recompiling them as well.
|
||||||
|
var advancedCustomModelRegexCache sync.Map // pattern string -> *regexp.Regexp (nil when invalid)
|
||||||
|
|
||||||
|
func compileAdvancedCustomModelRegex(pattern string) *regexp.Regexp {
|
||||||
|
if cached, ok := advancedCustomModelRegexCache.Load(pattern); ok {
|
||||||
|
re, _ := cached.(*regexp.Regexp)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
re = nil
|
||||||
|
}
|
||||||
|
advancedCustomModelRegexCache.Store(pattern, re)
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchAdvancedCustomRouteModelRule(rule string, model string) bool {
|
||||||
|
if !strings.HasPrefix(rule, advancedCustomModelRegexPrefix) {
|
||||||
|
return rule == model
|
||||||
|
}
|
||||||
|
pattern := strings.TrimPrefix(rule, advancedCustomModelRegexPrefix)
|
||||||
|
if pattern == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
re := compileAdvancedCustomModelRegex(pattern)
|
||||||
|
return re != nil && re.MatchString(model)
|
||||||
|
}
|
||||||
|
|
||||||
func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool {
|
func matchAdvancedCustomIncomingPath(configuredPath string, requestPath string) bool {
|
||||||
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
|
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
|
||||||
return true
|
return true
|
||||||
@@ -144,13 +284,14 @@ func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath
|
|||||||
|
|
||||||
func IsAdvancedCustomConverterAllowed(converter string) bool {
|
func IsAdvancedCustomConverterAllowed(converter string) bool {
|
||||||
switch converter {
|
switch converter {
|
||||||
case AdvancedCustomConverterNone,
|
case advancedCustomConverterNone,
|
||||||
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
|
advancedCustomConverterClaudeMessagesToOpenAIChat,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
|
advancedCustomConverterOpenAIChatToClaudeMessages,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
|
advancedCustomConverterOpenAIChatToOpenAIResponses,
|
||||||
AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
|
advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
|
advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
|
advancedCustomConverterGeminiContentToOpenAIChat,
|
||||||
|
advancedCustomConverterOpenAIChatToGeminiContent:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -165,14 +306,14 @@ func (c *AdvancedCustomConfig) Validate() error {
|
|||||||
return fmt.Errorf("advanced_custom requires at least one route")
|
return fmt.Errorf("advanced_custom requires at least one route")
|
||||||
}
|
}
|
||||||
|
|
||||||
seenPaths := make(map[string]struct{}, len(c.Routes))
|
paths := make(map[string]*advancedCustomPathModelState, len(c.Routes))
|
||||||
for i := range c.Routes {
|
for i := range c.Routes {
|
||||||
route := c.Routes[i]
|
route := c.Routes[i]
|
||||||
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
|
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
|
||||||
upstreamPath := strings.TrimSpace(route.UpstreamPath)
|
upstreamPath := strings.TrimSpace(route.UpstreamPath)
|
||||||
route.Converter = strings.TrimSpace(route.Converter)
|
route.Converter = strings.TrimSpace(route.Converter)
|
||||||
if route.Converter == "" {
|
if route.Converter == "" {
|
||||||
route.Converter = AdvancedCustomConverterNone
|
route.Converter = advancedCustomConverterNone
|
||||||
}
|
}
|
||||||
|
|
||||||
if route.IncomingPath == "" {
|
if route.IncomingPath == "" {
|
||||||
@@ -184,10 +325,9 @@ func (c *AdvancedCustomConfig) Validate() error {
|
|||||||
if strings.Contains(route.IncomingPath, "?") {
|
if strings.Contains(route.IncomingPath, "?") {
|
||||||
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
|
||||||
}
|
}
|
||||||
if _, exists := seenPaths[route.IncomingPath]; exists {
|
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
|
||||||
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must be unique: %s", i, route.IncomingPath)
|
return err
|
||||||
}
|
}
|
||||||
seenPaths[route.IncomingPath] = struct{}{}
|
|
||||||
|
|
||||||
if upstreamPath == "" {
|
if upstreamPath == "" {
|
||||||
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
|
||||||
@@ -210,6 +350,79 @@ func (c *AdvancedCustomConfig) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type advancedCustomPathModelState struct {
|
||||||
|
catchAllIndex int
|
||||||
|
modelIndexes map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAdvancedCustomRouteModels(index int, incomingPath string, models []string, paths map[string]*advancedCustomPathModelState) error {
|
||||||
|
state := paths[incomingPath]
|
||||||
|
if state == nil {
|
||||||
|
state = &advancedCustomPathModelState{
|
||||||
|
catchAllIndex: -1,
|
||||||
|
modelIndexes: make(map[string]int),
|
||||||
|
}
|
||||||
|
paths[incomingPath] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedModels := normalizeAdvancedCustomRouteModels(models)
|
||||||
|
if len(normalizedModels) == 0 {
|
||||||
|
if state.catchAllIndex >= 0 {
|
||||||
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all already exists for incoming_path: %s", index, incomingPath)
|
||||||
|
}
|
||||||
|
state.catchAllIndex = index
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.catchAllIndex >= 0 {
|
||||||
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].models catch-all route must be last for incoming_path: %s", index, incomingPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
seenInRoute := make(map[string]struct{}, len(normalizedModels))
|
||||||
|
for _, model := range normalizedModels {
|
||||||
|
if err := validateAdvancedCustomRouteModelRule(index, incomingPath, model); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, exists := seenInRoute[model]; exists {
|
||||||
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].models contains duplicate model for incoming_path %s: %s", index, incomingPath, model)
|
||||||
|
}
|
||||||
|
seenInRoute[model] = struct{}{}
|
||||||
|
if existingIndex, exists := state.modelIndexes[model]; exists {
|
||||||
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].models overlaps with advanced_routes[%d] for incoming_path %s: %s", index, existingIndex, incomingPath, model)
|
||||||
|
}
|
||||||
|
state.modelIndexes[model] = index
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAdvancedCustomRouteModelRule(index int, incomingPath string, model string) error {
|
||||||
|
if !strings.HasPrefix(model, advancedCustomModelRegexPrefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pattern := strings.TrimPrefix(model, advancedCustomModelRegexPrefix)
|
||||||
|
if pattern == "" {
|
||||||
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is empty for incoming_path %s: %s", index, incomingPath, model)
|
||||||
|
}
|
||||||
|
if _, err := regexp.Compile(pattern); err != nil {
|
||||||
|
return fmt.Errorf("advanced_custom.advanced_routes[%d].models regex is invalid for incoming_path %s: %s", index, incomingPath, model)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdvancedCustomRouteModels(models []string) []string {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
normalized := make([]string, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model != "" {
|
||||||
|
normalized = append(normalized, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
|
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
|
||||||
if strings.HasPrefix(upstreamPath, "/") {
|
if strings.HasPrefix(upstreamPath, "/") {
|
||||||
if strings.HasPrefix(upstreamPath, "//") {
|
if strings.HasPrefix(upstreamPath, "//") {
|
||||||
@@ -230,23 +443,27 @@ func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error
|
|||||||
|
|
||||||
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
|
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
|
||||||
switch converter {
|
switch converter {
|
||||||
case AdvancedCustomConverterNone:
|
case advancedCustomConverterNone:
|
||||||
return nil
|
return nil
|
||||||
case AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
|
case advancedCustomConverterClaudeMessagesToOpenAIChat:
|
||||||
if incomingPath == "/v1/messages" {
|
if incomingPath == "/v1/messages" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
|
case advancedCustomConverterOpenAIChatToClaudeMessages,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
|
advancedCustomConverterOpenAIChatToOpenAIResponses,
|
||||||
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
|
advancedCustomConverterOpenAIChatToGeminiContent:
|
||||||
if incomingPath == "/v1/chat/completions" {
|
if incomingPath == "/v1/chat/completions" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
|
case advancedCustomConverterOpenAIResponsesToOpenAIChat:
|
||||||
if incomingPath == "/v1/responses" {
|
if incomingPath == "/v1/responses" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
|
case advancedCustomConverterOpenAIResponsesToGemini:
|
||||||
|
if incomingPath == "/v1/responses" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case advancedCustomConverterGeminiContentToOpenAIChat:
|
||||||
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
|
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -13,12 +15,23 @@ func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/responses",
|
IncomingPath: "/v1/responses",
|
||||||
UpstreamPath: "/v1/chat/completions",
|
UpstreamPath: "/v1/chat/completions",
|
||||||
Converter: AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
require.NoError(t, valid.Validate())
|
require.NoError(t, valid.Validate())
|
||||||
|
|
||||||
|
validGemini := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, validGemini.Validate())
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
incomingPath string
|
incomingPath string
|
||||||
@@ -34,7 +47,7 @@ func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: tt.incomingPath,
|
IncomingPath: tt.incomingPath,
|
||||||
UpstreamPath: "/v1/chat/completions",
|
UpstreamPath: "/v1/chat/completions",
|
||||||
Converter: AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -44,3 +57,335 @@ func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomValidateDuplicateIncomingPathWithDisjointModels(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"gpt-4o"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini-2.5-flash"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, config.Validate())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsOverlappingModels(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"shared-model"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"shared-model"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "models overlaps")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsMultipleCatchAllRoutes(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "catch-all already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomValidateDuplicateIncomingPathRequiresCatchAllLast(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini-2.5-flash"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "catch-all route must be last")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomMatchPathForModel(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini-2.5-flash"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"gpt-4o"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/responses",
|
||||||
|
Converter: advancedCustomConverterNone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, config.Validate())
|
||||||
|
|
||||||
|
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
|
||||||
|
|
||||||
|
chatRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
|
||||||
|
|
||||||
|
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "unknown-model")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomMatchPathForModelRegexRules(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"re:(?i)^OAI-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/responses",
|
||||||
|
Converter: advancedCustomConverterNone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, config.Validate())
|
||||||
|
|
||||||
|
geminiRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, geminiRoute.Converter)
|
||||||
|
|
||||||
|
chatRoute, ok := config.MatchPathForModel("/v1/responses", "oai-test")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToOpenAIChat, chatRoute.Converter)
|
||||||
|
|
||||||
|
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gpt-4o")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomRouteModelRegexRulesAreCachedCompiled(t *testing.T) {
|
||||||
|
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-model"))
|
||||||
|
|
||||||
|
cached, ok := advancedCustomModelRegexCache.Load("^cache-probe-")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, cached)
|
||||||
|
_, isRegexp := cached.(*regexp.Regexp)
|
||||||
|
require.True(t, isRegexp)
|
||||||
|
|
||||||
|
// Invalid patterns never match and are cached as nil so they are not recompiled.
|
||||||
|
require.False(t, matchAdvancedCustomRouteModelRule("re:(", "anything"))
|
||||||
|
cached, ok = advancedCustomModelRegexCache.Load("(")
|
||||||
|
require.True(t, ok)
|
||||||
|
re, _ := cached.(*regexp.Regexp)
|
||||||
|
require.Nil(t, re)
|
||||||
|
|
||||||
|
// Cached entries keep matching correctly on subsequent calls.
|
||||||
|
require.True(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "cache-probe-other"))
|
||||||
|
require.False(t, matchAdvancedCustomRouteModelRule("re:^cache-probe-", "other-model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomMatchPathForModelExactRuleDoesNotMatchPrefix(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/responses",
|
||||||
|
Converter: advancedCustomConverterNone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, config.Validate())
|
||||||
|
|
||||||
|
fallbackRoute, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterNone, fallbackRoute.Converter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsInvalidRegexModels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
models []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "empty regex", models: []string{"re:"}, want: "regex is empty"},
|
||||||
|
{name: "invalid regex", models: []string{"re:["}, want: "regex is invalid"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: tt.models,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomValidateDuplicateIncomingPathRejectsDuplicateRegexModels(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := config.Validate()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "models overlaps")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomMatchPathForModelUsesFirstMatchingRegexRoute(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"gemini-2.5-flash"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, config.Validate())
|
||||||
|
|
||||||
|
route, ok := config.MatchPathForModel("/v1/responses", "gemini-2.5-flash")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, advancedCustomConverterOpenAIResponsesToGemini, route.Converter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdvancedCustomSupportedEndpointTypesForModel(t *testing.T) {
|
||||||
|
config := &AdvancedCustomConfig{
|
||||||
|
Routes: []AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: advancedCustomConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1beta/models/{model}:streamGenerateContent",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:streamGenerateContent",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Models: []string{"gpt-4o"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/messages",
|
||||||
|
UpstreamPath: "/v1/messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/custom/endpoint",
|
||||||
|
UpstreamPath: "/custom/endpoint",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, config.Validate())
|
||||||
|
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
constant.EndpointTypeGemini,
|
||||||
|
constant.EndpointTypeAnthropic,
|
||||||
|
}, config.SupportedEndpointTypesForModel("gemini-2.5-flash"))
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
constant.EndpointTypeAnthropic,
|
||||||
|
}, config.SupportedEndpointTypesForModel("gpt-4o"))
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeAnthropic,
|
||||||
|
}, config.SupportedEndpointTypesForModel("other-model"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -564,6 +564,7 @@ type ClaudeUsage struct {
|
|||||||
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
|
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
|
||||||
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
|
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
|
||||||
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
|
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
|
||||||
|
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClaudeCacheCreationUsage struct {
|
type ClaudeCacheCreationUsage struct {
|
||||||
|
|||||||
+44
-6
@@ -44,9 +44,9 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ToolConfig struct {
|
type ToolConfig struct {
|
||||||
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
||||||
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
|
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
|
||||||
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
|
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FunctionCallingConfig struct {
|
type FunctionCallingConfig struct {
|
||||||
@@ -455,9 +455,46 @@ type GeminiChatPromptFeedback struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeminiChatResponse struct {
|
type GeminiChatResponse struct {
|
||||||
Candidates []GeminiChatCandidate `json:"candidates"`
|
Candidates []GeminiChatCandidate `json:"candidates"`
|
||||||
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
|
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
|
||||||
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
|
UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
|
||||||
|
HasUsageMetadata bool `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON records whether Gemini returned usageMetadata while preserving
|
||||||
|
// the historical wire shape that always marshals the usageMetadata field.
|
||||||
|
//
|
||||||
|
// IMPORTANT: aux shadows GeminiChatResponse. Any field added to
|
||||||
|
// GeminiChatResponse must also be added to aux (and copied below), otherwise it
|
||||||
|
// is silently dropped during unmarshal.
|
||||||
|
func (r *GeminiChatResponse) UnmarshalJSON(data []byte) error {
|
||||||
|
var aux struct {
|
||||||
|
Candidates []GeminiChatCandidate `json:"candidates"`
|
||||||
|
PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
|
||||||
|
UsageMetadata *GeminiUsageMetadata `json:"usageMetadata"`
|
||||||
|
}
|
||||||
|
if err := common.Unmarshal(data, &aux); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Candidates = aux.Candidates
|
||||||
|
r.PromptFeedback = aux.PromptFeedback
|
||||||
|
r.HasUsageMetadata = aux.UsageMetadata != nil
|
||||||
|
if aux.UsageMetadata != nil {
|
||||||
|
r.UsageMetadata = *aux.UsageMetadata
|
||||||
|
} else {
|
||||||
|
r.UsageMetadata = GeminiUsageMetadata{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *GeminiChatResponse) GetUsageMetadata() *GeminiUsageMetadata {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if r.HasUsageMetadata || HasGeminiUsageMetadataTokens(&r.UsageMetadata) {
|
||||||
|
return &r.UsageMetadata
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiUsageMetadata struct {
|
type GeminiUsageMetadata struct {
|
||||||
@@ -470,6 +507,7 @@ type GeminiUsageMetadata struct {
|
|||||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||||
ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
|
ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
|
||||||
CandidatesTokensDetails []GeminiPromptTokensDetails `json:"candidatesTokensDetails"`
|
CandidatesTokensDetails []GeminiPromptTokensDetails `json:"candidatesTokensDetails"`
|
||||||
|
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiPromptTokensDetails struct {
|
type GeminiPromptTokensDetails struct {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeminiChatResponseUsageMetadataPresence(t *testing.T) {
|
||||||
|
var missing GeminiChatResponse
|
||||||
|
require.NoError(t, common.Unmarshal([]byte(`{"candidates":[]}`), &missing))
|
||||||
|
assert.False(t, missing.HasUsageMetadata)
|
||||||
|
assert.Nil(t, missing.GetUsageMetadata())
|
||||||
|
|
||||||
|
var empty GeminiChatResponse
|
||||||
|
require.NoError(t, common.Unmarshal([]byte(`{"candidates":[],"usageMetadata":{}}`), &empty))
|
||||||
|
assert.True(t, empty.HasUsageMetadata)
|
||||||
|
require.NotNil(t, empty.GetUsageMetadata())
|
||||||
|
assert.False(t, HasGeminiUsageMetadataTokens(empty.GetUsageMetadata()))
|
||||||
|
|
||||||
|
var populated GeminiChatResponse
|
||||||
|
require.NoError(t, common.Unmarshal([]byte(`{"candidates":[],"usageMetadata":{"promptTokenCount":3}}`), &populated))
|
||||||
|
assert.True(t, populated.HasUsageMetadata)
|
||||||
|
require.NotNil(t, populated.GetUsageMetadata())
|
||||||
|
assert.True(t, HasGeminiUsageMetadataTokens(populated.GetUsageMetadata()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiChatResponseMarshalKeepsUsageMetadataField(t *testing.T) {
|
||||||
|
data, err := common.Marshal(GeminiChatResponse{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), `"usageMetadata"`)
|
||||||
|
}
|
||||||
@@ -221,12 +221,13 @@ type CompletionsStreamResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
TotalTokens int `json:"total_tokens"`
|
TotalTokens int `json:"total_tokens"`
|
||||||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
||||||
UsageSemantic string `json:"usage_semantic,omitempty"`
|
UsageSemantic string `json:"usage_semantic,omitempty"`
|
||||||
UsageSource string `json:"usage_source,omitempty"`
|
UsageSource string `json:"usage_source,omitempty"`
|
||||||
|
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
|
||||||
|
|
||||||
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
|
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
|
||||||
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
|
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ func main() {
|
|||||||
go model.SyncChannelCache(common.SyncFrequency)
|
go model.SyncChannelCache(common.SyncFrequency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warm pricing after channel cache initialization so Advanced Custom
|
||||||
|
// endpoint inference can read cached route settings on first request.
|
||||||
|
model.GetPricing()
|
||||||
|
|
||||||
// 热更新配置
|
// 热更新配置
|
||||||
go model.SyncOptions(common.SyncFrequency)
|
go model.SyncOptions(common.SyncFrequency)
|
||||||
|
|
||||||
@@ -330,9 +334,6 @@ func InitResources() error {
|
|||||||
// 清理旧的磁盘缓存文件
|
// 清理旧的磁盘缓存文件
|
||||||
common.CleanupOldCacheFiles()
|
common.CleanupOldCacheFiles()
|
||||||
|
|
||||||
// 初始化模型
|
|
||||||
model.GetPricing()
|
|
||||||
|
|
||||||
// Initialize SQL Database
|
// Initialize SQL Database
|
||||||
err = model.InitLogDB()
|
err = model.InitLogDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ func Distribute() func(c *gin.Context) {
|
|||||||
affinityUsable := false
|
affinityUsable := false
|
||||||
preferred, err := model.CacheGetChannel(preferredChannelID)
|
preferred, err := model.CacheGetChannel(preferredChannelID)
|
||||||
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled &&
|
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled &&
|
||||||
channelSupportsRequestPath(preferred, c.Request.URL.Path) {
|
channelSupportsRequestPath(preferred, c.Request.URL.Path, modelRequest.Model) {
|
||||||
if usingGroup == "auto" {
|
if usingGroup == "auto" {
|
||||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||||
autoGroups := service.GetUserAutoGroup(userGroup)
|
autoGroups := service.GetUserAutoGroup(userGroup)
|
||||||
@@ -172,7 +172,7 @@ func Distribute() func(c *gin.Context) {
|
|||||||
// channelSupportsRequestPath reports whether a channel can serve the request path.
|
// channelSupportsRequestPath reports whether a channel can serve the request path.
|
||||||
// Only Advanced Custom (type 58) channels are path-checked; all other channel types
|
// Only Advanced Custom (type 58) channels are path-checked; all other channel types
|
||||||
// always pass. A type-58 channel is usable only when one of its routes matches.
|
// always pass. A type-58 channel is usable only when one of its routes matches.
|
||||||
func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool {
|
func channelSupportsRequestPath(channel *model.Channel, requestPath string, requestModel string) bool {
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
config := channel.GetOtherSettings().AdvancedCustom
|
config := channel.GetOtherSettings().AdvancedCustom
|
||||||
return config != nil && config.SupportsPath(requestPath)
|
return config != nil && config.SupportsPathForModel(requestPath, requestModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getModelFromRequest 从请求中读取模型信息
|
// getModelFromRequest 从请求中读取模型信息
|
||||||
|
|||||||
+8
-7
@@ -121,7 +121,7 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
abilities = filterAbilitiesByRequestPath(abilities, requestPath)
|
abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
|
||||||
channel := Channel{}
|
channel := Channel{}
|
||||||
if len(abilities) > 0 {
|
if len(abilities) > 0 {
|
||||||
// Randomly choose one
|
// Randomly choose one
|
||||||
@@ -146,11 +146,12 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
|
|||||||
return &channel, err
|
return &channel, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterAbilitiesByRequestPath restricts candidates by request path for the DB
|
// filterAbilitiesByRequestPathAndModel restricts candidates by request path and
|
||||||
// (non-memory-cache) selection path. Only Advanced Custom (type 58) channels are
|
// model for the DB (non-memory-cache) selection path. Only Advanced Custom
|
||||||
// path-checked: kept only when one of their routes matches requestPath; all other
|
// (type 58) channels are path-checked: kept only when one of their routes matches
|
||||||
// channel types always pass. When requestPath is empty, filtering is skipped.
|
// requestPath and model; all other channel types always pass. When requestPath is
|
||||||
func filterAbilitiesByRequestPath(abilities []Ability, requestPath string) []Ability {
|
// empty, filtering is skipped.
|
||||||
|
func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath string, model string) []Ability {
|
||||||
if requestPath == "" || len(abilities) == 0 {
|
if requestPath == "" || len(abilities) == 0 {
|
||||||
return abilities
|
return abilities
|
||||||
}
|
}
|
||||||
@@ -185,7 +186,7 @@ func filterAbilitiesByRequestPath(abilities []Ability, requestPath string) []Abi
|
|||||||
filtered = append(filtered, ability)
|
filtered = append(filtered, ability)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if config != nil && config.SupportsPath(requestPath) {
|
if config != nil && config.SupportsPathForModel(requestPath, model) {
|
||||||
filtered = append(filtered, ability)
|
filtered = append(filtered, ability)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-9
@@ -25,6 +25,7 @@ var channelSyncLock sync.RWMutex
|
|||||||
|
|
||||||
func InitChannelCache() {
|
func InitChannelCache() {
|
||||||
if !common.MemoryCacheEnabled {
|
if !common.MemoryCacheEnabled {
|
||||||
|
InvalidatePricingCache()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newChannelId2channel := make(map[int]*Channel)
|
newChannelId2channel := make(map[int]*Channel)
|
||||||
@@ -94,6 +95,11 @@ func InitChannelCache() {
|
|||||||
channelsIDM = newChannelId2channel
|
channelsIDM = newChannelId2channel
|
||||||
channel2advancedCustomConfig = newChannel2advancedCustomConfig
|
channel2advancedCustomConfig = newChannel2advancedCustomConfig
|
||||||
channelSyncLock.Unlock()
|
channelSyncLock.Unlock()
|
||||||
|
// Lock ordering: InvalidatePricingCache acquires updatePricingLock, and
|
||||||
|
// GetPricing (holding updatePricingLock) nests channelSyncLock.RLock via
|
||||||
|
// loadPricingAdvancedCustomConfigs. channelSyncLock MUST be released before
|
||||||
|
// invalidating the pricing cache, otherwise the reversed order deadlocks.
|
||||||
|
InvalidatePricingCache()
|
||||||
common.SysLog("channels synced from database")
|
common.SysLog("channels synced from database")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +121,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
|
|||||||
defer channelSyncLock.RUnlock()
|
defer channelSyncLock.RUnlock()
|
||||||
|
|
||||||
// First, try to find channels with the exact model name.
|
// First, try to find channels with the exact model name.
|
||||||
channels := filterChannelsByRequestPath(group2model2channels[group][model], requestPath)
|
channels := filterChannelsByRequestPathAndModel(group2model2channels[group][model], requestPath, model)
|
||||||
|
|
||||||
// If no channels found, try to find channels with the normalized model name.
|
// If no channels found, try to find channels with the normalized model name.
|
||||||
if len(channels) == 0 {
|
if len(channels) == 0 {
|
||||||
normalizedModel := ratio_setting.FormatMatchingModelName(model)
|
normalizedModel := ratio_setting.FormatMatchingModelName(model)
|
||||||
channels = filterChannelsByRequestPath(group2model2channels[group][normalizedModel], requestPath)
|
channels = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(channels) == 0 {
|
if len(channels) == 0 {
|
||||||
@@ -202,12 +208,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
|
|||||||
return nil, errors.New("channel not found")
|
return nil, errors.New("channel not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterChannelsByRequestPath restricts candidates by request path. Only Advanced
|
// filterChannelsByRequestPathAndModel restricts candidates by request path and
|
||||||
// Custom (type 58) channels are path-checked: they are kept only when one of their
|
// model. Only Advanced Custom (type 58) channels are path-checked: they are kept
|
||||||
// configured routes matches requestPath. All other channel types always pass.
|
// only when one of their configured routes matches requestPath and model. All
|
||||||
// When requestPath is empty (non-relay callers) filtering is skipped.
|
// other channel types always pass. When requestPath is empty, filtering is skipped.
|
||||||
// Caller must hold channelSyncLock (read lock). The cached slice is never mutated.
|
// Caller must hold channelSyncLock (read lock). The cached slice is never mutated.
|
||||||
func filterChannelsByRequestPath(channels []int, requestPath string) []int {
|
func filterChannelsByRequestPathAndModel(channels []int, requestPath string, model string) []int {
|
||||||
if requestPath == "" || len(channels) == 0 {
|
if requestPath == "" || len(channels) == 0 {
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
@@ -223,7 +229,7 @@ func filterChannelsByRequestPath(channels []int, requestPath string) []int {
|
|||||||
filtered = append(filtered, channelId)
|
filtered = append(filtered, channelId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPath(requestPath) {
|
if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPathForModel(requestPath, model) {
|
||||||
filtered = append(filtered, channelId)
|
filtered = append(filtered, channelId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,8 +298,8 @@ func CacheUpdateChannel(channel *Channel) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
channelSyncLock.Lock()
|
channelSyncLock.Lock()
|
||||||
defer channelSyncLock.Unlock()
|
|
||||||
if channel == nil {
|
if channel == nil {
|
||||||
|
channelSyncLock.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,5 +310,20 @@ func CacheUpdateChannel(channel *Channel) {
|
|||||||
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
|
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
|
||||||
}
|
}
|
||||||
channelsIDM[channel.Id] = channel
|
channelsIDM[channel.Id] = channel
|
||||||
|
if channel2advancedCustomConfig == nil {
|
||||||
|
channel2advancedCustomConfig = make(map[int]*dto.AdvancedCustomConfig)
|
||||||
|
}
|
||||||
|
delete(channel2advancedCustomConfig, channel.Id)
|
||||||
|
if channel.Type == constant.ChannelTypeAdvancedCustom {
|
||||||
|
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
|
||||||
|
channel2advancedCustomConfig[channel.Id] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||||
|
// Lock ordering: do NOT hold channelSyncLock while calling
|
||||||
|
// InvalidatePricingCache. GetPricing acquires updatePricingLock first and then
|
||||||
|
// channelSyncLock.RLock (via loadPricingAdvancedCustomConfigs); acquiring
|
||||||
|
// updatePricingLock while holding channelSyncLock would be an AB-BA deadlock.
|
||||||
|
channelSyncLock.Unlock()
|
||||||
|
InvalidatePricingCache()
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-9
@@ -1,7 +1,6 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
@@ -107,6 +107,76 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
|
|||||||
return make([]constant.EndpointType, 0)
|
return make([]constant.EndpointType, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPricingEndpointTypesForAbility(ability AbilityWithChannel, advancedCustomConfigs map[int]*dto.AdvancedCustomConfig) []constant.EndpointType {
|
||||||
|
if ability.ChannelType != constant.ChannelTypeAdvancedCustom {
|
||||||
|
return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||||
|
}
|
||||||
|
if config := advancedCustomConfigs[ability.ChannelId]; config != nil {
|
||||||
|
return config.SupportedEndpointTypesForModel(ability.Model)
|
||||||
|
}
|
||||||
|
return common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadPricingAdvancedCustomConfigs runs inside updatePricing while
|
||||||
|
// updatePricingLock is held, and nests channelSyncLock.RLock. This defines the
|
||||||
|
// global lock order updatePricingLock -> channelSyncLock: any code path holding
|
||||||
|
// channelSyncLock must release it before touching the pricing cache (see
|
||||||
|
// InitChannelCache / CacheUpdateChannel), otherwise it deadlocks.
|
||||||
|
// The returned configs are pointers shared with the channel cache; they are
|
||||||
|
// replaced wholesale on update and never mutated in place, so reading them after
|
||||||
|
// RUnlock is safe.
|
||||||
|
func loadPricingAdvancedCustomConfigs(enableAbilities []AbilityWithChannel) map[int]*dto.AdvancedCustomConfig {
|
||||||
|
channelIDs := make([]int, 0)
|
||||||
|
seen := make(map[int]struct{})
|
||||||
|
for _, ability := range enableAbilities {
|
||||||
|
if ability.ChannelType != constant.ChannelTypeAdvancedCustom {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[ability.ChannelId]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ability.ChannelId] = struct{}{}
|
||||||
|
channelIDs = append(channelIDs, ability.ChannelId)
|
||||||
|
}
|
||||||
|
if len(channelIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configs := make(map[int]*dto.AdvancedCustomConfig, len(channelIDs))
|
||||||
|
if common.MemoryCacheEnabled {
|
||||||
|
channelSyncLock.RLock()
|
||||||
|
defer channelSyncLock.RUnlock()
|
||||||
|
for _, channelID := range channelIDs {
|
||||||
|
if config := channel2advancedCustomConfig[channelID]; config != nil {
|
||||||
|
configs[channelID] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, channelID := range channelIDs {
|
||||||
|
channel, err := CacheGetChannel(channelID)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog(fmt.Sprintf("load advanced custom channel settings error: channel_id=%d, error=%v", channelID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if channel.Type != constant.ChannelTypeAdvancedCustom {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if config := channel.GetOtherSettings().AdvancedCustom; config != nil {
|
||||||
|
configs[channelID] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configs
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPricingEndpoint(endpoints []string, endpoint string) []string {
|
||||||
|
if endpoint == "" || common.StringsContains(endpoints, endpoint) {
|
||||||
|
return endpoints
|
||||||
|
}
|
||||||
|
return append(endpoints, endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
func updatePricing() {
|
func updatePricing() {
|
||||||
//modelRatios := common.GetModelRatios()
|
//modelRatios := common.GetModelRatios()
|
||||||
enableAbilities, err := GetAllEnableAbilityWithChannels()
|
enableAbilities, err := GetAllEnableAbilityWithChannels()
|
||||||
@@ -201,11 +271,12 @@ func updatePricing() {
|
|||||||
|
|
||||||
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
|
||||||
modelSupportEndpointsStr := make(map[string][]string)
|
modelSupportEndpointsStr := make(map[string][]string)
|
||||||
|
advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities)
|
||||||
|
|
||||||
// 先根据已有能力填充原生端点
|
// 先根据已有能力填充原生端点
|
||||||
for _, ability := range enableAbilities {
|
for _, ability := range enableAbilities {
|
||||||
endpoints := modelSupportEndpointsStr[ability.Model]
|
endpoints := modelSupportEndpointsStr[ability.Model]
|
||||||
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
|
channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs)
|
||||||
for _, channelType := range channelTypes {
|
for _, channelType := range channelTypes {
|
||||||
if !common.StringsContains(endpoints, string(channelType)) {
|
if !common.StringsContains(endpoints, string(channelType)) {
|
||||||
endpoints = append(endpoints, string(channelType))
|
endpoints = append(endpoints, string(channelType))
|
||||||
@@ -214,20 +285,18 @@ func updatePricing() {
|
|||||||
modelSupportEndpointsStr[ability.Model] = endpoints
|
modelSupportEndpointsStr[ability.Model] = endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再补充模型自定义端点:若配置有效则替换默认端点,不做合并
|
// 再补充模型自定义端点:若配置有效则追加到已有推断,不再裁剪渠道真实能力
|
||||||
for modelName, meta := range metaMap {
|
for modelName, meta := range metaMap {
|
||||||
if strings.TrimSpace(meta.Endpoints) == "" {
|
if strings.TrimSpace(meta.Endpoints) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var raw map[string]interface{}
|
var raw map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||||
endpoints := make([]string, 0, len(raw))
|
endpoints := modelSupportEndpointsStr[modelName]
|
||||||
for k, v := range raw {
|
for k, v := range raw {
|
||||||
switch v.(type) {
|
switch v.(type) {
|
||||||
case string, map[string]interface{}:
|
case string, map[string]interface{}:
|
||||||
if !common.StringsContains(endpoints, k) {
|
endpoints = appendPricingEndpoint(endpoints, k)
|
||||||
endpoints = append(endpoints, k)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(endpoints) > 0 {
|
if len(endpoints) > 0 {
|
||||||
@@ -264,7 +333,7 @@ func updatePricing() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var raw map[string]interface{}
|
var raw map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
|
||||||
for k, v := range raw {
|
for k, v := range raw {
|
||||||
switch val := v.(type) {
|
switch val := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resetPricingEndpointTestTables(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
originalMemoryCacheEnabled := common.MemoryCacheEnabled
|
||||||
|
common.MemoryCacheEnabled = true
|
||||||
|
require.NoError(t, DB.AutoMigrate(&Channel{}, &Ability{}, &Model{}, &Vendor{}))
|
||||||
|
for _, table := range []string{"abilities", "channels", "models", "vendors"} {
|
||||||
|
require.NoError(t, DB.Exec("DELETE FROM "+table).Error)
|
||||||
|
}
|
||||||
|
InitChannelCache()
|
||||||
|
InvalidatePricingCache()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
for _, table := range []string{"abilities", "channels", "models", "vendors"} {
|
||||||
|
require.NoError(t, DB.Exec("DELETE FROM "+table).Error)
|
||||||
|
}
|
||||||
|
InitChannelCache()
|
||||||
|
InvalidatePricingCache()
|
||||||
|
common.MemoryCacheEnabled = originalMemoryCacheEnabled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertPricingEndpointChannel(t *testing.T, channelID int, channelType int, settings dto.ChannelOtherSettings) {
|
||||||
|
t.Helper()
|
||||||
|
channel := &Channel{
|
||||||
|
Id: channelID,
|
||||||
|
Type: channelType,
|
||||||
|
Key: fmt.Sprintf("key-%d", channelID),
|
||||||
|
Status: common.ChannelStatusEnabled,
|
||||||
|
Name: fmt.Sprintf("channel-%d", channelID),
|
||||||
|
}
|
||||||
|
if settings.AdvancedCustom != nil {
|
||||||
|
channel.SetOtherSettings(settings)
|
||||||
|
}
|
||||||
|
require.NoError(t, DB.Create(channel).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertPricingEndpointAbility(t *testing.T, channelID int, modelName string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, DB.Create(&Ability{
|
||||||
|
Group: "default",
|
||||||
|
Model: modelName,
|
||||||
|
ChannelId: channelID,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingEndpointAdvancedCustomConfig(routes ...dto.AdvancedCustomRoute) dto.ChannelOtherSettings {
|
||||||
|
return dto.ChannelOtherSettings{
|
||||||
|
AdvancedCustom: &dto.AdvancedCustomConfig{
|
||||||
|
Routes: routes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingEndpointTypesByModel(t *testing.T) map[string][]constant.EndpointType {
|
||||||
|
t.Helper()
|
||||||
|
InitChannelCache()
|
||||||
|
return pricingEndpointTypesFromPricing(GetPricing())
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingEndpointTypesFromPricing(pricings []Pricing) map[string][]constant.EndpointType {
|
||||||
|
byModel := make(map[string][]constant.EndpointType)
|
||||||
|
for _, pricing := range pricings {
|
||||||
|
byModel[pricing.ModelName] = pricing.SupportedEndpointTypes
|
||||||
|
}
|
||||||
|
return byModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPricingAdvancedCustomUsesConfiguredEndpointTypes(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 101, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
},
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
insertPricingEndpointAbility(t, 101, "gemini-2.5-flash")
|
||||||
|
insertPricingEndpointAbility(t, 101, "gpt-4o")
|
||||||
|
|
||||||
|
byModel := pricingEndpointTypesByModel(t)
|
||||||
|
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
}, byModel["gemini-2.5-flash"])
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
}, byModel["gpt-4o"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPricingModelMetadataEndpointsMergeWithAdvancedCustomInference(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 103, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
insertPricingEndpointAbility(t, 103, "gemini-2.5-flash")
|
||||||
|
require.NoError(t, DB.Create(&Model{
|
||||||
|
ModelName: "gemini-2.5-flash",
|
||||||
|
Endpoints: `{
|
||||||
|
"openai": "/v1/chat/completions"
|
||||||
|
}`,
|
||||||
|
Status: 1,
|
||||||
|
NameRule: NameRuleExact,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
byModel := pricingEndpointTypesByModel(t)
|
||||||
|
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
}, byModel["gemini-2.5-flash"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPricingModelMetadataEndpointsCanProvideEndpointWithoutChannelInference(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 104, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
insertPricingEndpointAbility(t, 104, "metadata-only-model")
|
||||||
|
require.NoError(t, DB.Create(&Model{
|
||||||
|
ModelName: "metadata-only-model",
|
||||||
|
Endpoints: `{
|
||||||
|
"openai": "/v1/chat/completions"
|
||||||
|
}`,
|
||||||
|
Status: 1,
|
||||||
|
NameRule: NameRuleExact,
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
byModel := pricingEndpointTypesByModel(t)
|
||||||
|
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, byModel["metadata-only-model"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPricingAdvancedCustomMissingConfigFallsBackToChannelType(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 102, constant.ChannelTypeAdvancedCustom, dto.ChannelOtherSettings{})
|
||||||
|
insertPricingEndpointAbility(t, 102, "gpt-4o")
|
||||||
|
|
||||||
|
byModel := pricingEndpointTypesByModel(t)
|
||||||
|
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, byModel["gpt-4o"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPricingNativeChannelEndpointTypesUnchanged(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 201, constant.ChannelTypeOpenAI, dto.ChannelOtherSettings{})
|
||||||
|
insertPricingEndpointChannel(t, 202, constant.ChannelTypeGemini, dto.ChannelOtherSettings{})
|
||||||
|
insertPricingEndpointChannel(t, 203, constant.ChannelTypeAnthropic, dto.ChannelOtherSettings{})
|
||||||
|
insertPricingEndpointAbility(t, 201, "gpt-4o")
|
||||||
|
insertPricingEndpointAbility(t, 202, "gemini-2.5-flash")
|
||||||
|
insertPricingEndpointAbility(t, 203, "claude-3-5-sonnet")
|
||||||
|
|
||||||
|
byModel := pricingEndpointTypesByModel(t)
|
||||||
|
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, byModel["gpt-4o"])
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI}, byModel["gemini-2.5-flash"])
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI}, byModel["claude-3-5-sonnet"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitChannelCacheInvalidatesPricingCache(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 301, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
insertPricingEndpointAbility(t, 301, "gemini-3.5-flash")
|
||||||
|
InitChannelCache()
|
||||||
|
|
||||||
|
initial := pricingEndpointTypesByModel(t)
|
||||||
|
require.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, initial["gemini-3.5-flash"])
|
||||||
|
|
||||||
|
var channel Channel
|
||||||
|
require.NoError(t, DB.First(&channel, "id = ?", 301).Error)
|
||||||
|
channel.SetOtherSettings(pricingEndpointAdvancedCustomConfig(
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
},
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
require.NoError(t, DB.Model(&Channel{}).Where("id = ?", 301).Update("settings", channel.OtherSettings).Error)
|
||||||
|
InitChannelCache()
|
||||||
|
|
||||||
|
updated := pricingEndpointTypesByModel(t)
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
}, updated["gemini-3.5-flash"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitChannelCacheInvalidatesStartupPricingBuiltBeforeChannelCache(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
insertPricingEndpointChannel(t, 302, constant.ChannelTypeAdvancedCustom, pricingEndpointAdvancedCustomConfig(
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
},
|
||||||
|
dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
Models: []string{"re:^gemini-"},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
insertPricingEndpointAbility(t, 302, "gemini-3.5-flash")
|
||||||
|
|
||||||
|
staleByModel := pricingEndpointTypesFromPricing(GetPricing())
|
||||||
|
require.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, staleByModel["gemini-3.5-flash"])
|
||||||
|
|
||||||
|
InitChannelCache()
|
||||||
|
|
||||||
|
rebuiltByModel := pricingEndpointTypesFromPricing(GetPricing())
|
||||||
|
assert.Equal(t, []constant.EndpointType{
|
||||||
|
constant.EndpointTypeOpenAI,
|
||||||
|
constant.EndpointTypeOpenAIResponse,
|
||||||
|
}, rebuiltByModel["gemini-3.5-flash"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheUpdateChannelSyncsAdvancedCustomConfig(t *testing.T) {
|
||||||
|
resetPricingEndpointTestTables(t)
|
||||||
|
|
||||||
|
channel := &Channel{
|
||||||
|
Id: 401,
|
||||||
|
Type: constant.ChannelTypeAdvancedCustom,
|
||||||
|
Key: "key-401",
|
||||||
|
Status: common.ChannelStatusEnabled,
|
||||||
|
Name: "channel-401",
|
||||||
|
}
|
||||||
|
channel.SetOtherSettings(pricingEndpointAdvancedCustomConfig(dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: "openai_responses_to_gemini_generate_content",
|
||||||
|
}))
|
||||||
|
CacheUpdateChannel(channel)
|
||||||
|
|
||||||
|
require.NotNil(t, channel2advancedCustomConfig[401])
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAIResponse}, channel2advancedCustomConfig[401].SupportedEndpointTypesForModel("gemini-3.5-flash"))
|
||||||
|
|
||||||
|
channel.SetOtherSettings(pricingEndpointAdvancedCustomConfig(dto.AdvancedCustomRoute{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
}))
|
||||||
|
CacheUpdateChannel(channel)
|
||||||
|
|
||||||
|
require.NotNil(t, channel2advancedCustomConfig[401])
|
||||||
|
assert.Equal(t, []constant.EndpointType{constant.EndpointTypeOpenAI}, channel2advancedCustomConfig[401].SupportedEndpointTypesForModel("gemini-3.5-flash"))
|
||||||
|
|
||||||
|
channel.Type = constant.ChannelTypeOpenAI
|
||||||
|
CacheUpdateChannel(channel)
|
||||||
|
|
||||||
|
assert.Nil(t, channel2advancedCustomConfig[401])
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
@@ -1,111 +0,0 @@
|
|||||||
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="140" y1="30" x2="1450" y2="880" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#F3FCFF"/>
|
|
||||||
<stop offset="0.46" stop-color="#FFFFFF"/>
|
|
||||||
<stop offset="1" stop-color="#FFF6FD"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="logoGradient" x1="430" y1="252" x2="1110" y2="616" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#22D3EE"/>
|
|
||||||
<stop offset="0.48" stop-color="#7C3AED"/>
|
|
||||||
<stop offset="1" stop-color="#F15BB5"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="lineGradient" x1="180" y1="120" x2="1440" y2="780" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#22D3EE" stop-opacity="0.28"/>
|
|
||||||
<stop offset="0.52" stop-color="#7C3AED" stop-opacity="0.22"/>
|
|
||||||
<stop offset="1" stop-color="#F15BB5" stop-opacity="0.28"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="softBand" x1="300" y1="190" x2="1320" y2="740" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#22D3EE" stop-opacity="0.14"/>
|
|
||||||
<stop offset="0.5" stop-color="#7C3AED" stop-opacity="0.1"/>
|
|
||||||
<stop offset="1" stop-color="#F15BB5" stop-opacity="0.14"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
|
||||||
<feDropShadow dx="0" dy="24" stdDeviation="34" flood-color="#64748B" flood-opacity="0.16"/>
|
|
||||||
</filter>
|
|
||||||
<filter id="logoShadow" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB">
|
|
||||||
<feDropShadow dx="0" dy="18" stdDeviation="24" flood-color="#7C3AED" flood-opacity="0.18"/>
|
|
||||||
</filter>
|
|
||||||
<clipPath id="logoClip">
|
|
||||||
<circle cx="800" cy="206" r="62"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="1600" height="900" rx="0" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<g opacity="0.65">
|
|
||||||
<path d="M-90 654C145 538 294 690 495 594C702 495 678 278 908 252C1129 227 1214 421 1697 277" stroke="url(#lineGradient)" stroke-width="2.4"/>
|
|
||||||
<path d="M-62 266C184 413 331 178 539 280C772 394 782 632 1008 624C1216 617 1332 424 1664 548" stroke="url(#lineGradient)" stroke-width="2"/>
|
|
||||||
<path d="M122 780C392 624 561 779 742 622C905 480 806 323 992 222C1172 124 1322 196 1512 96" stroke="url(#lineGradient)" stroke-width="1.7"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g opacity="0.22">
|
|
||||||
<path d="M204 154H1396" stroke="#7C3AED" stroke-width="1"/>
|
|
||||||
<path d="M204 746H1396" stroke="#22D3EE" stroke-width="1"/>
|
|
||||||
<path d="M280 80V820" stroke="#F15BB5" stroke-width="1"/>
|
|
||||||
<path d="M1320 80V820" stroke="#22D3EE" stroke-width="1"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g filter="url(#softShadow)">
|
|
||||||
<path d="M310 648C393 543 504 498 637 512C742 524 837 579 941 559C1075 533 1173 414 1291 457C1376 488 1420 580 1428 665C1284 741 1117 784 932 790C702 797 492 750 310 648Z" fill="url(#softBand)"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g opacity="0.9">
|
|
||||||
<circle cx="376" cy="232" r="7" fill="#22D3EE"/>
|
|
||||||
<circle cx="1266" cy="236" r="7" fill="#F15BB5"/>
|
|
||||||
<circle cx="1328" cy="642" r="6" fill="#7C3AED"/>
|
|
||||||
<circle cx="254" cy="606" r="5" fill="#F15BB5"/>
|
|
||||||
<circle cx="1172" cy="154" r="4" fill="#22D3EE"/>
|
|
||||||
<circle cx="506" cy="760" r="4" fill="#7C3AED"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g opacity="0.82">
|
|
||||||
<path d="M1190 358L1202 386L1232 398L1202 410L1190 438L1178 410L1148 398L1178 386L1190 358Z" fill="#22D3EE"/>
|
|
||||||
<path d="M420 416L430 440L456 450L430 460L420 484L410 460L384 450L410 440L420 416Z" fill="#F15BB5"/>
|
|
||||||
<path d="M1074 718L1083 739L1105 748L1083 757L1074 778L1065 757L1043 748L1065 739L1074 718Z" fill="#7C3AED"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="translate(0 10)">
|
|
||||||
<g filter="url(#logoShadow)">
|
|
||||||
<circle cx="800" cy="206" r="78" fill="#FFFFFF"/>
|
|
||||||
<circle cx="800" cy="206" r="77" stroke="#E8F3FF" stroke-width="2"/>
|
|
||||||
<image href="/Users/caion/GolandProjects/new-api/web/default/public/logo.png" x="738" y="144" width="124" height="124" clip-path="url(#logoClip)" preserveAspectRatio="xMidYMid meet"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<text x="800" y="344" text-anchor="middle"
|
|
||||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
|
||||||
font-size="46" font-weight="760" letter-spacing="0" fill="#0F172A">NewAPI</text>
|
|
||||||
|
|
||||||
<text x="800" y="516" text-anchor="middle"
|
|
||||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
|
||||||
font-size="170" font-weight="860" letter-spacing="0" fill="url(#logoGradient)">40K</text>
|
|
||||||
|
|
||||||
<text x="800" y="616" text-anchor="middle"
|
|
||||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
|
||||||
font-size="76" font-weight="780" letter-spacing="0" fill="#101828">Stars</text>
|
|
||||||
|
|
||||||
<text x="800" y="688" text-anchor="middle"
|
|
||||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
|
||||||
font-size="34" font-weight="560" letter-spacing="0" fill="#475569">Thank you, builders</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="translate(104 86)">
|
|
||||||
<image href="/Users/caion/GolandProjects/new-api/web/default/public/logo.png" x="0" y="0" width="42" height="42" preserveAspectRatio="xMidYMid meet"/>
|
|
||||||
<text x="58" y="30"
|
|
||||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
|
||||||
font-size="26" font-weight="730" letter-spacing="0" fill="#162033">NewAPI</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="translate(1262 96)">
|
|
||||||
<rect x="0" y="0" width="234" height="54" rx="27" fill="#FFFFFF" fill-opacity="0.78" stroke="#D7E7F5"/>
|
|
||||||
<text x="117" y="35" text-anchor="middle"
|
|
||||||
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
|
|
||||||
font-size="23" font-weight="660" letter-spacing="0" fill="#334155">newapi.ai</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="translate(118 774)">
|
|
||||||
<path d="M0 22H246" stroke="#22D3EE" stroke-width="5" stroke-linecap="round"/>
|
|
||||||
<path d="M281 22H464" stroke="#7C3AED" stroke-width="5" stroke-linecap="round"/>
|
|
||||||
<path d="M500 22H652" stroke="#F15BB5" stroke-width="5" stroke-linecap="round"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -17,6 +17,7 @@ import (
|
|||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
@@ -48,20 +49,19 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if converter == dto.AdvancedCustomConverterNone {
|
if converter == relayconvert.ConverterNone {
|
||||||
return a.convertOpenAICompatibleRequest(c, info, request)
|
return a.convertOpenAICompatibleRequest(c, info, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch converter {
|
switch converter {
|
||||||
case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages:
|
case relayconvert.ConverterOpenAIChatToClaudeMessages,
|
||||||
return a.claudeAdaptor.ConvertOpenAIRequest(c, info, request)
|
relayconvert.ConverterOpenAIChatToOpenAIResponses,
|
||||||
case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses:
|
relayconvert.ConverterOpenAIChatToGeminiContent:
|
||||||
if request == nil {
|
result, err := service.ConvertRequestByID(c, info, converter, request)
|
||||||
return nil, errors.New("request is nil")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return service.ChatCompletionsRequestToResponsesRequest(request)
|
return result.Value, nil
|
||||||
case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
|
|
||||||
return a.geminiAdaptor.ConvertOpenAIRequest(c, info, request)
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("converter %q does not support OpenAI chat completions requests", converter)
|
return nil, fmt.Errorf("converter %q does not support OpenAI chat completions requests", converter)
|
||||||
}
|
}
|
||||||
@@ -74,10 +74,18 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch converter {
|
switch converter {
|
||||||
case dto.AdvancedCustomConverterNone:
|
case relayconvert.ConverterNone:
|
||||||
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
|
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
|
||||||
case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
|
case relayconvert.ConverterClaudeMessagesToOpenAIChat:
|
||||||
return a.convertClaudeToOpenAICompatibleRequest(c, info, request)
|
result, err := service.ConvertRequestByID(c, info, converter, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
chatRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return a.convertOpenAICompatibleRequest(c, info, chatRequest)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter)
|
return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter)
|
||||||
}
|
}
|
||||||
@@ -90,10 +98,18 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch converter {
|
switch converter {
|
||||||
case dto.AdvancedCustomConverterNone:
|
case relayconvert.ConverterNone:
|
||||||
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
|
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
|
||||||
case dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
|
case relayconvert.ConverterGeminiContentToOpenAIChat:
|
||||||
return a.convertGeminiToOpenAICompatibleRequest(c, info, request)
|
result, err := service.ConvertRequestByID(c, info, converter, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
chatRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return a.convertOpenAICompatibleRequest(c, info, chatRequest)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter)
|
return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter)
|
||||||
}
|
}
|
||||||
@@ -105,14 +121,28 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
switch converter {
|
switch converter {
|
||||||
case dto.AdvancedCustomConverterNone:
|
case relayconvert.ConverterNone:
|
||||||
return a.convertOpenAICompatibleResponsesRequest(c, info, request)
|
return a.convertOpenAICompatibleResponsesRequest(c, info, request)
|
||||||
case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
|
case relayconvert.ConverterOpenAIResponsesToOpenAIChat:
|
||||||
chatReq, err := service.ResponsesRequestToChatCompletionsRequest(&request)
|
result, err := service.ConvertRequestByID(c, info, converter, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.convertOpenAICompatibleRequest(c, info, chatReq)
|
chatRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return a.convertOpenAICompatibleRequest(c, info, chatRequest)
|
||||||
|
case relayconvert.ConverterOpenAIResponsesToGemini:
|
||||||
|
result, err := service.ConvertRequestByID(c, info, converter, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return geminiRequest, nil
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter)
|
return nil, fmt.Errorf("converter %q does not support OpenAI Responses requests", converter)
|
||||||
}
|
}
|
||||||
@@ -123,7 +153,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if converter != dto.AdvancedCustomConverterNone {
|
if converter != relayconvert.ConverterNone {
|
||||||
return nil, fmt.Errorf("converter %q does not support embedding requests", converter)
|
return nil, fmt.Errorf("converter %q does not support embedding requests", converter)
|
||||||
}
|
}
|
||||||
return a.convertOpenAICompatibleEmbeddingRequest(c, info, request)
|
return a.convertOpenAICompatibleEmbeddingRequest(c, info, request)
|
||||||
@@ -134,7 +164,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if converter != dto.AdvancedCustomConverterNone {
|
if converter != relayconvert.ConverterNone {
|
||||||
return nil, fmt.Errorf("converter %q does not support audio requests", converter)
|
return nil, fmt.Errorf("converter %q does not support audio requests", converter)
|
||||||
}
|
}
|
||||||
return a.convertOpenAICompatibleAudioRequest(c, info, request)
|
return a.convertOpenAICompatibleAudioRequest(c, info, request)
|
||||||
@@ -145,7 +175,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if converter != dto.AdvancedCustomConverterNone {
|
if converter != relayconvert.ConverterNone {
|
||||||
return nil, fmt.Errorf("converter %q does not support image requests", converter)
|
return nil, fmt.Errorf("converter %q does not support image requests", converter)
|
||||||
}
|
}
|
||||||
return a.convertOpenAICompatibleImageRequest(c, info, request)
|
return a.convertOpenAICompatibleImageRequest(c, info, request)
|
||||||
@@ -194,7 +224,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
|
|||||||
if err := a.resolve(c, info); err != nil {
|
if err := a.resolve(c, info); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !a.converted && a.converter != dto.AdvancedCustomConverterNone {
|
if !a.converted && a.converter != relayconvert.ConverterNone {
|
||||||
return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body")
|
return nil, errors.New("advanced custom converter routes cannot be used with pass-through request body")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,21 +245,23 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch a.converter {
|
switch a.converter {
|
||||||
case dto.AdvancedCustomConverterNone:
|
case relayconvert.ConverterNone:
|
||||||
return a.doNativeResponse(c, resp, info)
|
return a.doNativeResponse(c, resp, info)
|
||||||
case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
|
case relayconvert.ConverterClaudeMessagesToOpenAIChat,
|
||||||
dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
|
relayconvert.ConverterGeminiContentToOpenAIChat:
|
||||||
return a.openaiAdaptor.DoResponse(c, resp, info)
|
return a.openaiAdaptor.DoResponse(c, resp, info)
|
||||||
case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages:
|
case relayconvert.ConverterOpenAIChatToClaudeMessages:
|
||||||
return a.claudeAdaptor.DoResponse(c, resp, info)
|
return a.claudeAdaptor.DoResponse(c, resp, info)
|
||||||
case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
|
case relayconvert.ConverterOpenAIChatToGeminiContent:
|
||||||
return a.geminiAdaptor.DoResponse(c, resp, info)
|
return a.geminiAdaptor.DoResponse(c, resp, info)
|
||||||
case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses:
|
case relayconvert.ConverterOpenAIResponsesToGemini:
|
||||||
|
return a.geminiAdaptor.DoResponse(c, resp, info)
|
||||||
|
case relayconvert.ConverterOpenAIChatToOpenAIResponses:
|
||||||
if info.IsStream {
|
if info.IsStream {
|
||||||
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
|
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
|
||||||
}
|
}
|
||||||
return openai.OaiResponsesToChatHandler(c, info, resp)
|
return openai.OaiResponsesToChatHandler(c, info, resp)
|
||||||
case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
|
case relayconvert.ConverterOpenAIResponsesToOpenAIChat:
|
||||||
if info.IsStream {
|
if info.IsStream {
|
||||||
return openai.OaiChatToResponsesStreamHandler(c, info, resp)
|
return openai.OaiChatToResponsesStreamHandler(c, info, resp)
|
||||||
}
|
}
|
||||||
@@ -286,18 +318,18 @@ func (a *Adaptor) resolve(c *gin.Context, info *relaycommon.RelayInfo) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
incomingPath := incomingRequestPath(c, info)
|
incomingPath := incomingRequestPath(c, info)
|
||||||
route, ok := config.MatchPath(incomingPath)
|
route, ok := config.MatchPathForModel(incomingPath, info.OriginModelName)
|
||||||
if ok {
|
if ok {
|
||||||
route.Converter = strings.TrimSpace(route.Converter)
|
route.Converter = strings.TrimSpace(route.Converter)
|
||||||
if route.Converter == "" {
|
if route.Converter == "" {
|
||||||
route.Converter = dto.AdvancedCustomConverterNone
|
route.Converter = relayconvert.ConverterNone
|
||||||
}
|
}
|
||||||
a.route = route
|
a.route = route
|
||||||
a.converter = route.Converter
|
a.converter = route.Converter
|
||||||
a.resolved = true
|
a.resolved = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("advanced custom channel does not support request path: %s", incomingPath)
|
return fmt.Errorf("advanced custom channel does not support request path %s for model %s", incomingPath, info.OriginModelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
|
func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
|
||||||
@@ -391,7 +423,8 @@ func applyUpstreamPathTemplate(upstreamPath string, info *relaycommon.RelayInfo)
|
|||||||
func shouldUseGeminiStreamURL(converter string, info *relaycommon.RelayInfo) bool {
|
func shouldUseGeminiStreamURL(converter string, info *relaycommon.RelayInfo) bool {
|
||||||
return info != nil &&
|
return info != nil &&
|
||||||
info.IsStream &&
|
info.IsStream &&
|
||||||
converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent
|
(converter == relayconvert.ConverterOpenAIChatToGeminiContent ||
|
||||||
|
converter == relayconvert.ConverterOpenAIResponsesToGemini)
|
||||||
}
|
}
|
||||||
|
|
||||||
func useGeminiStreamGenerateContentURL(parsedURL *url.URL) {
|
func useGeminiStreamGenerateContentURL(parsedURL *url.URL) {
|
||||||
@@ -406,8 +439,8 @@ func useGeminiStreamGenerateContentURL(parsedURL *url.URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func shouldApplyClaudeHeaders(converter string, info *relaycommon.RelayInfo) bool {
|
func shouldApplyClaudeHeaders(converter string, info *relaycommon.RelayInfo) bool {
|
||||||
return converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages ||
|
return converter == relayconvert.ConverterOpenAIChatToClaudeMessages ||
|
||||||
(converter == dto.AdvancedCustomConverterNone && info != nil && info.RelayFormat == types.RelayFormatClaude)
|
(converter == relayconvert.ConverterNone && info != nil && info.RelayFormat == types.RelayFormatClaude)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyClaudeHeaders(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) {
|
func applyClaudeHeaders(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) {
|
||||||
@@ -443,22 +476,6 @@ func (a *Adaptor) convertOpenAICompatibleRequest(c *gin.Context, info *relaycomm
|
|||||||
return converted, err
|
return converted, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) convertClaudeToOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
|
||||||
old := info.ChannelType
|
|
||||||
info.ChannelType = constant.ChannelTypeOpenAI
|
|
||||||
converted, err := a.openaiAdaptor.ConvertClaudeRequest(c, info, request)
|
|
||||||
info.ChannelType = old
|
|
||||||
return converted, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Adaptor) convertGeminiToOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
|
||||||
old := info.ChannelType
|
|
||||||
info.ChannelType = constant.ChannelTypeOpenAI
|
|
||||||
converted, err := a.openaiAdaptor.ConvertGeminiRequest(c, info, request)
|
|
||||||
info.ChannelType = old
|
|
||||||
return converted, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Adaptor) convertOpenAICompatibleResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
func (a *Adaptor) convertOpenAICompatibleResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||||
old := info.ChannelType
|
old := info.ChannelType
|
||||||
info.ChannelType = constant.ChannelTypeOpenAI
|
info.ChannelType = constant.ChannelTypeOpenAI
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package advancedcustom
|
package advancedcustom
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,6 +13,8 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -24,7 +28,7 @@ func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/messages",
|
IncomingPath: "/v1/messages",
|
||||||
UpstreamPath: "https://upstream.example/v1/chat/completions?existing=1",
|
UpstreamPath: "https://upstream.example/v1/chat/completions?existing=1",
|
||||||
Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
|
Converter: relayconvert.ConverterClaudeMessagesToOpenAIChat,
|
||||||
Auth: &dto.AdvancedCustomRouteAuth{
|
Auth: &dto.AdvancedCustomRouteAuth{
|
||||||
Type: dto.AdvancedCustomAuthTypeQuery,
|
Type: dto.AdvancedCustomAuthTypeQuery,
|
||||||
Name: "api_key",
|
Name: "api_key",
|
||||||
@@ -54,7 +58,7 @@ func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/chat/completions",
|
IncomingPath: "/v1/chat/completions",
|
||||||
UpstreamPath: "/proxy/v1/chat/completions?existing=1",
|
UpstreamPath: "/proxy/v1/chat/completions?existing=1",
|
||||||
Converter: dto.AdvancedCustomConverterNone,
|
Converter: relayconvert.ConverterNone,
|
||||||
Auth: &dto.AdvancedCustomRouteAuth{
|
Auth: &dto.AdvancedCustomRouteAuth{
|
||||||
Type: dto.AdvancedCustomAuthTypeQuery,
|
Type: dto.AdvancedCustomAuthTypeQuery,
|
||||||
Name: "api_key",
|
Name: "api_key",
|
||||||
@@ -84,7 +88,7 @@ func TestAdaptorReturnsErrorWhenUpstreamPathNeedsMissingBaseURL(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/chat/completions",
|
IncomingPath: "/v1/chat/completions",
|
||||||
UpstreamPath: "/v1/chat/completions",
|
UpstreamPath: "/v1/chat/completions",
|
||||||
Converter: dto.AdvancedCustomConverterNone,
|
Converter: relayconvert.ConverterNone,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -102,7 +106,7 @@ func TestAdaptorSetupRequestHeaderUsesDefaultBearerAuth(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/chat/completions",
|
IncomingPath: "/v1/chat/completions",
|
||||||
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
||||||
Converter: dto.AdvancedCustomConverterNone,
|
Converter: relayconvert.ConverterNone,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -120,7 +124,7 @@ func TestAdaptorSetupRequestHeaderUsesConfiguredHeaderAuth(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/chat/completions",
|
IncomingPath: "/v1/chat/completions",
|
||||||
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
||||||
Converter: dto.AdvancedCustomConverterNone,
|
Converter: relayconvert.ConverterNone,
|
||||||
Auth: &dto.AdvancedCustomRouteAuth{
|
Auth: &dto.AdvancedCustomRouteAuth{
|
||||||
Type: dto.AdvancedCustomAuthTypeHeader,
|
Type: dto.AdvancedCustomAuthTypeHeader,
|
||||||
Name: "x-api-key",
|
Name: "x-api-key",
|
||||||
@@ -144,7 +148,7 @@ func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/messages",
|
IncomingPath: "/v1/messages",
|
||||||
UpstreamPath: "https://api.anthropic.com/v1/messages",
|
UpstreamPath: "https://api.anthropic.com/v1/messages",
|
||||||
Converter: dto.AdvancedCustomConverterNone,
|
Converter: relayconvert.ConverterNone,
|
||||||
Auth: &dto.AdvancedCustomRouteAuth{
|
Auth: &dto.AdvancedCustomRouteAuth{
|
||||||
Type: dto.AdvancedCustomAuthTypeHeader,
|
Type: dto.AdvancedCustomAuthTypeHeader,
|
||||||
Name: "x-api-key",
|
Name: "x-api-key",
|
||||||
@@ -169,7 +173,7 @@ func TestAdaptorReturnsErrorWhenNoRouteMatchesPath(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/messages",
|
IncomingPath: "/v1/messages",
|
||||||
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
||||||
Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
|
Converter: relayconvert.ConverterClaudeMessagesToOpenAIChat,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -187,7 +191,7 @@ func TestAdaptorReplacesModelPlaceholderInRouteURL(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/chat/completions",
|
IncomingPath: "/v1/chat/completions",
|
||||||
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
|
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
|
||||||
Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent,
|
Converter: relayconvert.ConverterOpenAIChatToGeminiContent,
|
||||||
Auth: &dto.AdvancedCustomRouteAuth{
|
Auth: &dto.AdvancedCustomRouteAuth{
|
||||||
Type: dto.AdvancedCustomAuthTypeQuery,
|
Type: dto.AdvancedCustomAuthTypeQuery,
|
||||||
Name: "key",
|
Name: "key",
|
||||||
@@ -215,7 +219,7 @@ func TestAdaptorSwitchesGeminiGenerateContentURLForStream(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/chat/completions",
|
IncomingPath: "/v1/chat/completions",
|
||||||
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?existing=1",
|
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?existing=1",
|
||||||
Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent,
|
Converter: relayconvert.ConverterOpenAIChatToGeminiContent,
|
||||||
Auth: &dto.AdvancedCustomRouteAuth{
|
Auth: &dto.AdvancedCustomRouteAuth{
|
||||||
Type: dto.AdvancedCustomAuthTypeQuery,
|
Type: dto.AdvancedCustomAuthTypeQuery,
|
||||||
Name: "key",
|
Name: "key",
|
||||||
@@ -264,7 +268,7 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1beta/models/{model}:generateContent",
|
IncomingPath: "/v1beta/models/{model}:generateContent",
|
||||||
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
UpstreamPath: "https://upstream.example/v1/chat/completions",
|
||||||
Converter: dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
|
Converter: relayconvert.ConverterGeminiContentToOpenAIChat,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -287,13 +291,17 @@ func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
|
|||||||
{
|
{
|
||||||
IncomingPath: "/v1/responses",
|
IncomingPath: "/v1/responses",
|
||||||
UpstreamPath: "/v1/chat/completions",
|
UpstreamPath: "/v1/chat/completions",
|
||||||
Converter: dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
|
Converter: relayconvert.ConverterOpenAIResponsesToOpenAIChat,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
info.RelayMode = relayconstant.RelayModeResponses
|
info.RelayMode = relayconstant.RelayModeResponses
|
||||||
info.RequestURLPath = "/v1/responses"
|
info.RequestURLPath = "/v1/responses"
|
||||||
c := advancedCustomGinContext("/v1/responses")
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
converted, err := adaptor.ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{
|
converted, err := adaptor.ConvertOpenAIResponsesRequest(c, info, dto.OpenAIResponsesRequest{
|
||||||
Model: "gpt-test",
|
Model: "gpt-test",
|
||||||
@@ -318,15 +326,339 @@ func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
|
|||||||
assert.Equal(t, "/v1/chat/completions", parsedURL.Path)
|
assert.Equal(t, "/v1/chat/completions", parsedURL.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdaptorSelectsDuplicateResponsesRoutesByModel(t *testing.T) {
|
||||||
|
config := &dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: relayconvert.ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
Models: []string{"gpt-test"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: relayconvert.ConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini-test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chatAdaptor := &Adaptor{}
|
||||||
|
chatInfo := advancedCustomRelayInfo(config)
|
||||||
|
chatInfo.RelayFormat = types.RelayFormatOpenAIResponses
|
||||||
|
chatInfo.RelayMode = relayconstant.RelayModeResponses
|
||||||
|
chatInfo.RequestURLPath = "/v1/responses"
|
||||||
|
chatInfo.OriginModelName = "gpt-test"
|
||||||
|
chatInfo.UpstreamModelName = "gpt-test"
|
||||||
|
chatConverted, err := chatAdaptor.ConvertOpenAIResponsesRequest(advancedCustomGinContext("/v1/responses"), chatInfo, dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Input: mustAdvancedCustomRawMessage(t, "hello"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, ok := chatConverted.(*dto.GeneralOpenAIRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
geminiAdaptor := &Adaptor{}
|
||||||
|
geminiInfo := advancedCustomRelayInfo(config)
|
||||||
|
geminiInfo.RelayFormat = types.RelayFormatOpenAIResponses
|
||||||
|
geminiInfo.RelayMode = relayconstant.RelayModeResponses
|
||||||
|
geminiInfo.RequestURLPath = "/v1/responses"
|
||||||
|
geminiInfo.OriginModelName = "gemini-test"
|
||||||
|
geminiInfo.UpstreamModelName = "gemini-test"
|
||||||
|
geminiInfo.IsStream = true
|
||||||
|
geminiConverted, err := geminiAdaptor.ConvertOpenAIResponsesRequest(advancedCustomGinContext("/v1/responses"), geminiInfo, dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Input: mustAdvancedCustomRawMessage(t, "hello"),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, ok = geminiConverted.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
requestURL, err := geminiAdaptor.GetRequestURL(geminiInfo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
parsedURL, err := url.Parse(requestURL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "/v1beta/models/gemini-test:streamGenerateContent", parsedURL.Path)
|
||||||
|
assert.Equal(t, "sse", parsedURL.Query().Get("alt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorResponsesToGeminiUsesResponsesBridge(t *testing.T) {
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: relayconvert.ConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini-test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.RelayFormat = types.RelayFormatOpenAIResponses
|
||||||
|
info.RelayMode = relayconstant.RelayModeResponses
|
||||||
|
info.RequestURLPath = "/v1/responses"
|
||||||
|
info.OriginModelName = "gemini-test"
|
||||||
|
info.UpstreamModelName = "gemini-test"
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
payload := dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{
|
||||||
|
{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: "hello"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 2,
|
||||||
|
CandidatesTokenCount: 3,
|
||||||
|
TotalTokenCount: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, err := common.Marshal(payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
usage, newAPIError := adaptor.DoResponse(c, &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
}, info)
|
||||||
|
require.Nil(t, newAPIError)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
|
||||||
|
got := recorder.Body.String()
|
||||||
|
assert.Contains(t, got, `"object":"response"`)
|
||||||
|
assert.Contains(t, got, `"type":"output_text"`)
|
||||||
|
assert.Contains(t, got, `"text":"hello"`)
|
||||||
|
assert.NotContains(t, got, `"candidates"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorResponsesToGeminiAddsThoughtSignatureForFunctionCallHistory(t *testing.T) {
|
||||||
|
geminiSettings := model_setting.GetGeminiSettings()
|
||||||
|
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = true
|
||||||
|
t.Cleanup(func() {
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/responses",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: relayconvert.ConverterOpenAIResponsesToGemini,
|
||||||
|
Models: []string{"gemini-test"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.RelayFormat = types.RelayFormatOpenAIResponses
|
||||||
|
info.RelayMode = relayconstant.RelayModeResponses
|
||||||
|
info.RequestURLPath = "/v1/responses"
|
||||||
|
info.OriginModelName = "gemini-test"
|
||||||
|
info.UpstreamModelName = "gemini-test"
|
||||||
|
|
||||||
|
converted, err := adaptor.ConvertOpenAIResponsesRequest(advancedCustomGinContext("/v1/responses"), info, dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Input: mustAdvancedCustomRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "glob",
|
||||||
|
"arguments": map[string]any{"query": "*"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": []map[string]any{{"path": "report.md"}},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tools: mustAdvancedCustomRawMessage(t, []map[string]any{
|
||||||
|
{"type": "function", "name": "glob", "parameters": map[string]any{"type": "object"}},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
geminiReq, ok := converted.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, geminiReq.Contents, 3)
|
||||||
|
require.Len(t, geminiReq.Contents[1].Parts, 1)
|
||||||
|
require.NotNil(t, geminiReq.Contents[1].Parts[0].FunctionCall)
|
||||||
|
assert.NotEmpty(t, geminiReq.Contents[1].Parts[0].ThoughtSignature)
|
||||||
|
require.Len(t, geminiReq.Contents[2].Parts, 1)
|
||||||
|
require.NotNil(t, geminiReq.Contents[2].Parts[0].FunctionResponse)
|
||||||
|
assert.Empty(t, geminiReq.Contents[2].Parts[0].ThoughtSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorConvertsOpenAIChatRequestToResponsesUpstream(t *testing.T) {
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/responses",
|
||||||
|
Converter: relayconvert.ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c := advancedCustomGinContext("/v1/chat/completions")
|
||||||
|
|
||||||
|
converted, err := adaptor.ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
responsesReq, ok := converted.(*dto.OpenAIResponsesRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "gpt-test", responsesReq.Model)
|
||||||
|
assert.NotEmpty(t, responsesReq.Input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorConvertsOpenAIChatRequestToClaudeUpstream(t *testing.T) {
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1/messages",
|
||||||
|
Converter: relayconvert.ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c := advancedCustomGinContext("/v1/chat/completions")
|
||||||
|
|
||||||
|
converted, err := adaptor.ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "claude-test",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
claudeReq, ok := converted.(*dto.ClaudeRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "claude-test", claudeReq.Model)
|
||||||
|
require.Len(t, claudeReq.Messages, 1)
|
||||||
|
assert.Equal(t, "user", claudeReq.Messages[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorConvertsOpenAIChatRequestToGeminiUpstream(t *testing.T) {
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/chat/completions",
|
||||||
|
UpstreamPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
Converter: relayconvert.ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.UpstreamModelName = "gemini-2.5-flash"
|
||||||
|
c := advancedCustomGinContext("/v1/chat/completions")
|
||||||
|
|
||||||
|
converted, err := adaptor.ConvertOpenAIRequest(c, info, &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gemini-2.5-flash",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
geminiReq, ok := converted.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, geminiReq.Contents, 1)
|
||||||
|
assert.Equal(t, "user", geminiReq.Contents[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorConvertsClaudeRequestToOpenAIChatUpstream(t *testing.T) {
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1/messages",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: relayconvert.ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.RelayFormat = types.RelayFormatClaude
|
||||||
|
info.RequestURLPath = "/v1/messages"
|
||||||
|
c := advancedCustomGinContext("/v1/messages")
|
||||||
|
|
||||||
|
converted, err := adaptor.ConvertClaudeRequest(c, info, &dto.ClaudeRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Messages: []dto.ClaudeMessage{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
chatReq, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "gpt-test", chatReq.Model)
|
||||||
|
require.Len(t, chatReq.Messages, 1)
|
||||||
|
assert.Equal(t, "user", chatReq.Messages[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptorConvertsGeminiRequestToOpenAIChatUpstream(t *testing.T) {
|
||||||
|
adaptor := &Adaptor{}
|
||||||
|
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
|
||||||
|
Routes: []dto.AdvancedCustomRoute{
|
||||||
|
{
|
||||||
|
IncomingPath: "/v1beta/models/{model}:generateContent",
|
||||||
|
UpstreamPath: "/v1/chat/completions",
|
||||||
|
Converter: relayconvert.ConverterGeminiContentToOpenAIChat,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.RelayFormat = types.RelayFormatGemini
|
||||||
|
info.RequestURLPath = "/v1beta/models/gemini-2.5-flash:generateContent"
|
||||||
|
info.UpstreamModelName = "gpt-test"
|
||||||
|
c := advancedCustomGinContext("/v1beta/models/gemini-2.5-flash:generateContent")
|
||||||
|
|
||||||
|
converted, err := adaptor.ConvertGeminiRequest(c, info, &dto.GeminiChatRequest{
|
||||||
|
Contents: []dto.GeminiChatContent{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: "hello"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
chatReq, ok := converted.(*dto.GeneralOpenAIRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "gpt-test", chatReq.Model)
|
||||||
|
require.Len(t, chatReq.Messages, 1)
|
||||||
|
assert.Equal(t, "user", chatReq.Messages[0].Role)
|
||||||
|
}
|
||||||
|
|
||||||
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
|
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
|
||||||
return &relaycommon.RelayInfo{
|
return &relaycommon.RelayInfo{
|
||||||
RelayFormat: types.RelayFormatOpenAI,
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
RelayMode: relayconstant.RelayModeChatCompletions,
|
RelayMode: relayconstant.RelayModeChatCompletions,
|
||||||
RequestURLPath: "/v1/chat/completions",
|
RequestURLPath: "/v1/chat/completions",
|
||||||
|
OriginModelName: "gpt-test",
|
||||||
ChannelMeta: &relaycommon.ChannelMeta{
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
ApiKey: "sk-test",
|
ApiKey: "sk-test",
|
||||||
ChannelBaseUrl: "https://fallback.example",
|
ChannelBaseUrl: "https://fallback.example",
|
||||||
ChannelType: constant.ChannelTypeAdvancedCustom,
|
ChannelType: constant.ChannelTypeAdvancedCustom,
|
||||||
|
UpstreamModelName: "gpt-test",
|
||||||
ChannelOtherSettings: dto.ChannelOtherSettings{
|
ChannelOtherSettings: dto.ChannelOtherSettings{
|
||||||
AdvancedCustom: config,
|
AdvancedCustom: config,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,10 +75,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
oaiReq, err := service.ClaudeToOpenAIRequest(*req, info)
|
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
oaiReq, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
if info.SupportStreamOptions && info.IsStream {
|
if info.SupportStreamOptions && info.IsStream {
|
||||||
oaiReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
|
oaiReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||||
}
|
}
|
||||||
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
|
logger.LogDebug(c, "fullRequestURL: %s", common.SanitizeURLForLog(fullRequestURL))
|
||||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new request failed: %w", err)
|
return nil, fmt.Errorf("new request failed: %w", err)
|
||||||
@@ -339,7 +339,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get request url failed: %w", err)
|
return nil, fmt.Errorf("get request url failed: %w", err)
|
||||||
}
|
}
|
||||||
logger.LogDebug(c, "fullRequestURL: %s", fullRequestURL)
|
logger.LogDebug(c, "fullRequestURL: %s", common.SanitizeURLForLog(fullRequestURL))
|
||||||
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new request failed: %w", err)
|
return nil, fmt.Errorf("new request failed: %w", err)
|
||||||
@@ -388,7 +388,7 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
|||||||
targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||||
targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
|
targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("dial failed to %s: %w", fullRequestURL, err)
|
return nil, fmt.Errorf("dial failed to %s: %w", common.SanitizeURLForLog(fullRequestURL), err)
|
||||||
}
|
}
|
||||||
// send request body
|
// send request body
|
||||||
//all, err := io.ReadAll(requestBody)
|
//all, err := io.ReadAll(requestBody)
|
||||||
|
|||||||
@@ -123,10 +123,14 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 原有的Claude模型处理逻辑
|
// 原有的Claude模型处理逻辑
|
||||||
claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request)
|
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to convert openai request to claude request")
|
return nil, errors.Wrap(err, "failed to convert openai request to claude request")
|
||||||
}
|
}
|
||||||
|
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", result.Value)
|
||||||
|
}
|
||||||
info.UpstreamModelName = claudeReq.Model
|
info.UpstreamModelName = claudeReq.Model
|
||||||
return claudeReq, err
|
return claudeReq, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/relay/channel"
|
"github.com/QuantumNous/new-api/relay/channel"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
@@ -95,7 +96,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
if request == nil {
|
if request == nil {
|
||||||
return nil, errors.New("request is nil")
|
return nil, errors.New("request is nil")
|
||||||
}
|
}
|
||||||
return RequestOpenAI2ClaudeMessage(c, *request)
|
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatClaude, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package claude
|
package claude
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,28 +9,18 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/logger"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/openrouter"
|
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/helper"
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
"github.com/QuantumNous/new-api/relay/reasonmap"
|
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
"github.com/QuantumNous/new-api/setting/reasoning"
|
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"github.com/tidwall/sjson"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
WebSearchMaxUsesLow = 1
|
|
||||||
WebSearchMaxUsesMedium = 5
|
|
||||||
WebSearchMaxUsesHigh = 10
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func stopReasonClaude2OpenAI(reason string) string {
|
func stopReasonClaude2OpenAI(reason string) string {
|
||||||
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
|
return relayconvert.StopReasonClaudeToOpenAI(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
||||||
@@ -44,629 +32,37 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
|
||||||
claudeTools := make([]any, 0, len(textRequest.Tools))
|
|
||||||
|
|
||||||
for _, tool := range textRequest.Tools {
|
|
||||||
if params, ok := tool.Function.Parameters.(map[string]any); ok {
|
|
||||||
claudeTool := dto.Tool{
|
|
||||||
Name: tool.Function.Name,
|
|
||||||
Description: tool.Function.Description,
|
|
||||||
}
|
|
||||||
claudeTool.InputSchema = make(map[string]interface{})
|
|
||||||
if params["type"] != nil {
|
|
||||||
claudeTool.InputSchema["type"] = params["type"].(string)
|
|
||||||
}
|
|
||||||
claudeTool.InputSchema["properties"] = params["properties"]
|
|
||||||
claudeTool.InputSchema["required"] = params["required"]
|
|
||||||
for s, a := range params {
|
|
||||||
if s == "type" || s == "properties" || s == "required" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
claudeTool.InputSchema[s] = a
|
|
||||||
}
|
|
||||||
claudeTools = append(claudeTools, &claudeTool)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web search tool
|
|
||||||
// https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool
|
|
||||||
if textRequest.WebSearchOptions != nil {
|
|
||||||
webSearchTool := dto.ClaudeWebSearchTool{
|
|
||||||
Type: "web_search_20250305",
|
|
||||||
Name: "web_search",
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 user_location
|
|
||||||
if textRequest.WebSearchOptions.UserLocation != nil {
|
|
||||||
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
|
|
||||||
Type: "approximate", // 固定为 "approximate"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 UserLocation JSON
|
|
||||||
var userLocationMap map[string]interface{}
|
|
||||||
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
|
||||||
// 检查是否有 approximate 字段
|
|
||||||
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
|
|
||||||
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
|
|
||||||
anthropicUserLocation.Timezone = timezone
|
|
||||||
}
|
|
||||||
if country, ok := approximateData["country"].(string); ok && country != "" {
|
|
||||||
anthropicUserLocation.Country = country
|
|
||||||
}
|
|
||||||
if region, ok := approximateData["region"].(string); ok && region != "" {
|
|
||||||
anthropicUserLocation.Region = region
|
|
||||||
}
|
|
||||||
if city, ok := approximateData["city"].(string); ok && city != "" {
|
|
||||||
anthropicUserLocation.City = city
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webSearchTool.UserLocation = anthropicUserLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 search_context_size 转换为 max_uses
|
|
||||||
if textRequest.WebSearchOptions.SearchContextSize != "" {
|
|
||||||
switch textRequest.WebSearchOptions.SearchContextSize {
|
|
||||||
case "low":
|
|
||||||
webSearchTool.MaxUses = WebSearchMaxUsesLow
|
|
||||||
case "medium":
|
|
||||||
webSearchTool.MaxUses = WebSearchMaxUsesMedium
|
|
||||||
case "high":
|
|
||||||
webSearchTool.MaxUses = WebSearchMaxUsesHigh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeTools = append(claudeTools, &webSearchTool)
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeRequest := dto.ClaudeRequest{
|
|
||||||
Model: textRequest.Model,
|
|
||||||
StopSequences: nil,
|
|
||||||
Temperature: textRequest.Temperature,
|
|
||||||
Tools: claudeTools,
|
|
||||||
}
|
|
||||||
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
|
|
||||||
claudeRequest.MaxTokens = common.GetPointer(maxTokens)
|
|
||||||
}
|
|
||||||
if textRequest.TopP != nil {
|
|
||||||
claudeRequest.TopP = common.GetPointer(*textRequest.TopP)
|
|
||||||
}
|
|
||||||
if textRequest.TopK != nil {
|
|
||||||
claudeRequest.TopK = common.GetPointer(*textRequest.TopK)
|
|
||||||
}
|
|
||||||
if textRequest.IsStream(nil) {
|
|
||||||
claudeRequest.Stream = common.GetPointer(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 tool_choice 和 parallel_tool_calls
|
|
||||||
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
|
|
||||||
claudeToolChoice := mapToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
|
|
||||||
if claudeToolChoice != nil {
|
|
||||||
claudeRequest.ToolChoice = claudeToolChoice
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
|
|
||||||
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
|
|
||||||
claudeRequest.MaxTokens = &defaultMaxTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
|
||||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
|
|
||||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
|
|
||||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
|
|
||||||
claudeRequest.Model = baseModel
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
|
||||||
Type: "adaptive",
|
|
||||||
}
|
|
||||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
|
||||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
|
||||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
|
||||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
|
||||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
|
||||||
claudeRequest.Thinking.Display = "summarized"
|
|
||||||
claudeRequest.Temperature = nil
|
|
||||||
claudeRequest.TopP = nil
|
|
||||||
claudeRequest.TopK = nil
|
|
||||||
} else {
|
|
||||||
claudeRequest.TopP = nil
|
|
||||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
|
||||||
}
|
|
||||||
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
|
||||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
|
||||||
|
|
||||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
|
||||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
|
||||||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
|
|
||||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
|
||||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
|
||||||
claudeRequest.Temperature = nil
|
|
||||||
claudeRequest.TopP = nil
|
|
||||||
claudeRequest.TopK = nil
|
|
||||||
} else {
|
|
||||||
// 因为BudgetTokens 必须大于1024
|
|
||||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
|
|
||||||
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BudgetTokens 为 max_tokens 的 80%
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
|
||||||
Type: "enabled",
|
|
||||||
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
|
||||||
}
|
|
||||||
// TODO: 临时处理
|
|
||||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
|
||||||
claudeRequest.TopP = nil
|
|
||||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
|
||||||
}
|
|
||||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
|
||||||
claudeRequest.Model = trimmedModel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if textRequest.ReasoningEffort != "" {
|
|
||||||
switch textRequest.ReasoningEffort {
|
|
||||||
case "low":
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
|
||||||
Type: "enabled",
|
|
||||||
BudgetTokens: common.GetPointer[int](1280),
|
|
||||||
}
|
|
||||||
case "medium":
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
|
||||||
Type: "enabled",
|
|
||||||
BudgetTokens: common.GetPointer[int](2048),
|
|
||||||
}
|
|
||||||
case "high":
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
|
||||||
Type: "enabled",
|
|
||||||
BudgetTokens: common.GetPointer[int](4096),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 指定了 reasoning 参数,覆盖 budgetTokens
|
|
||||||
if textRequest.Reasoning != nil {
|
|
||||||
var reasoning openrouter.RequestReasoning
|
|
||||||
if err := common.Unmarshal(textRequest.Reasoning, &reasoning); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
budgetTokens := reasoning.MaxTokens
|
|
||||||
if budgetTokens > 0 {
|
|
||||||
claudeRequest.Thinking = &dto.Thinking{
|
|
||||||
Type: "enabled",
|
|
||||||
BudgetTokens: &budgetTokens,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if textRequest.Stop != nil {
|
|
||||||
// stop maybe string/array string, convert to array string
|
|
||||||
switch textRequest.Stop.(type) {
|
|
||||||
case string:
|
|
||||||
claudeRequest.StopSequences = []string{textRequest.Stop.(string)}
|
|
||||||
case []interface{}:
|
|
||||||
stopSequences := make([]string, 0)
|
|
||||||
for _, stop := range textRequest.Stop.([]interface{}) {
|
|
||||||
stopSequences = append(stopSequences, stop.(string))
|
|
||||||
}
|
|
||||||
claudeRequest.StopSequences = stopSequences
|
|
||||||
}
|
|
||||||
}
|
|
||||||
formatMessages := make([]dto.Message, 0)
|
|
||||||
lastMessage := dto.Message{
|
|
||||||
Role: "tool",
|
|
||||||
}
|
|
||||||
for i, message := range textRequest.Messages {
|
|
||||||
if message.Role == "" {
|
|
||||||
textRequest.Messages[i].Role = "user"
|
|
||||||
}
|
|
||||||
fmtMessage := dto.Message{
|
|
||||||
Role: message.Role,
|
|
||||||
Content: message.Content,
|
|
||||||
}
|
|
||||||
if message.Role == "tool" {
|
|
||||||
fmtMessage.ToolCallId = message.ToolCallId
|
|
||||||
}
|
|
||||||
if message.Role == "assistant" && message.ToolCalls != nil {
|
|
||||||
fmtMessage.ToolCalls = message.ToolCalls
|
|
||||||
}
|
|
||||||
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
|
|
||||||
if lastMessage.IsStringContent() && message.IsStringContent() {
|
|
||||||
fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
|
|
||||||
// delete last message
|
|
||||||
formatMessages = formatMessages[:len(formatMessages)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
|
|
||||||
fmtMessage.SetStringContent("...")
|
|
||||||
}
|
|
||||||
formatMessages = append(formatMessages, fmtMessage)
|
|
||||||
lastMessage = fmtMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeMessages := make([]dto.ClaudeMessage, 0)
|
|
||||||
isFirstMessage := true
|
|
||||||
// 初始化system消息数组,用于累积多个system消息
|
|
||||||
var systemMessages []dto.ClaudeMediaMessage
|
|
||||||
|
|
||||||
for _, message := range formatMessages {
|
|
||||||
if message.Role == "system" {
|
|
||||||
// 根据Claude API规范,system字段使用数组格式更有通用性
|
|
||||||
if message.IsStringContent() {
|
|
||||||
if text := message.StringContent(); text != "" {
|
|
||||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
|
||||||
Type: "text",
|
|
||||||
Text: common.GetPointer[string](text),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
|
|
||||||
for _, ctx := range message.ParseContent() {
|
|
||||||
if ctx.Type == "text" && ctx.Text != "" {
|
|
||||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
|
||||||
Type: "text",
|
|
||||||
Text: common.GetPointer[string](ctx.Text),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// 未来可以在这里扩展对图片等其他类型的支持
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if isFirstMessage {
|
|
||||||
isFirstMessage = false
|
|
||||||
if message.Role != "user" {
|
|
||||||
// fix: first message is assistant, add user message
|
|
||||||
claudeMessage := dto.ClaudeMessage{
|
|
||||||
Role: "user",
|
|
||||||
Content: []dto.ClaudeMediaMessage{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: common.GetPointer[string]("..."),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
claudeMessages = append(claudeMessages, claudeMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
claudeMessage := dto.ClaudeMessage{
|
|
||||||
Role: message.Role,
|
|
||||||
}
|
|
||||||
if message.Role == "tool" {
|
|
||||||
if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" {
|
|
||||||
lastMessage := claudeMessages[len(claudeMessages)-1]
|
|
||||||
if content, ok := lastMessage.Content.(string); ok {
|
|
||||||
lastMessage.Content = []dto.ClaudeMediaMessage{
|
|
||||||
{
|
|
||||||
Type: "text",
|
|
||||||
Text: common.GetPointer[string](content),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lastMessage.Content = append(lastMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{
|
|
||||||
Type: "tool_result",
|
|
||||||
ToolUseId: message.ToolCallId,
|
|
||||||
Content: message.Content,
|
|
||||||
})
|
|
||||||
claudeMessages[len(claudeMessages)-1] = lastMessage
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
claudeMessage.Role = "user"
|
|
||||||
claudeMessage.Content = []dto.ClaudeMediaMessage{
|
|
||||||
{
|
|
||||||
Type: "tool_result",
|
|
||||||
ToolUseId: message.ToolCallId,
|
|
||||||
Content: message.Content,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if message.IsStringContent() && message.ToolCalls == nil {
|
|
||||||
text := message.StringContent()
|
|
||||||
if text == "" {
|
|
||||||
text = "..."
|
|
||||||
}
|
|
||||||
claudeMessage.Content = text
|
|
||||||
} else {
|
|
||||||
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
|
|
||||||
for _, mediaMessage := range message.ParseContent() {
|
|
||||||
switch mediaMessage.Type {
|
|
||||||
case "text":
|
|
||||||
if mediaMessage.Text != "" {
|
|
||||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
|
||||||
Type: "text",
|
|
||||||
Text: common.GetPointer[string](mediaMessage.Text),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
source := mediaMessage.ToFileSource()
|
|
||||||
if source == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
|
||||||
}
|
|
||||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
|
||||||
Source: &dto.ClaudeMessageSource{
|
|
||||||
Type: "base64",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
|
||||||
claudeMediaMessage.Type = "document"
|
|
||||||
} else {
|
|
||||||
claudeMediaMessage.Type = "image"
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeMediaMessage.Source.MediaType = mimeType
|
|
||||||
claudeMediaMessage.Source.Data = base64Data
|
|
||||||
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if message.ToolCalls != nil {
|
|
||||||
for _, toolCall := range message.ParseToolCalls() {
|
|
||||||
inputObj := make(map[string]any)
|
|
||||||
if args := toolCall.Function.Arguments; args != "" {
|
|
||||||
if err := json.Unmarshal([]byte(args), &inputObj); err != nil {
|
|
||||||
common.SysLog("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
|
||||||
Type: "tool_use",
|
|
||||||
Id: toolCall.ID,
|
|
||||||
Name: toolCall.Function.Name,
|
|
||||||
Input: inputObj,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
claudeMessage.Content = claudeMediaMessages
|
|
||||||
}
|
|
||||||
claudeMessages = append(claudeMessages, claudeMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置累积的system消息
|
|
||||||
if len(systemMessages) > 0 {
|
|
||||||
claudeRequest.System = systemMessages
|
|
||||||
}
|
|
||||||
|
|
||||||
claudeRequest.Prompt = ""
|
|
||||||
claudeRequest.Messages = claudeMessages
|
|
||||||
return &claudeRequest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
|
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
|
||||||
var response dto.ChatCompletionsStreamResponse
|
return relayconvert.StreamResponseClaude2OpenAI(claudeResponse)
|
||||||
response.Object = "chat.completion.chunk"
|
|
||||||
response.Model = claudeResponse.Model
|
|
||||||
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
|
|
||||||
tools := make([]dto.ToolCallResponse, 0)
|
|
||||||
fcIdx := 0
|
|
||||||
if claudeResponse.Index != nil {
|
|
||||||
fcIdx = *claudeResponse.Index
|
|
||||||
}
|
|
||||||
var choice dto.ChatCompletionsStreamResponseChoice
|
|
||||||
if claudeResponse.Type == "message_start" {
|
|
||||||
if claudeResponse.Message != nil {
|
|
||||||
response.Id = claudeResponse.Message.Id
|
|
||||||
response.Model = claudeResponse.Message.Model
|
|
||||||
}
|
|
||||||
//claudeUsage = &claudeResponse.Message.Usage
|
|
||||||
choice.Delta.SetContentString("")
|
|
||||||
choice.Delta.Role = "assistant"
|
|
||||||
} else if claudeResponse.Type == "content_block_start" {
|
|
||||||
if claudeResponse.ContentBlock != nil {
|
|
||||||
// 如果是文本块,尽可能发送首段文本(若存在)
|
|
||||||
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
|
|
||||||
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
|
|
||||||
}
|
|
||||||
if claudeResponse.ContentBlock.Type == "tool_use" {
|
|
||||||
tools = append(tools, dto.ToolCallResponse{
|
|
||||||
Index: common.GetPointer(fcIdx),
|
|
||||||
ID: claudeResponse.ContentBlock.Id,
|
|
||||||
Type: "function",
|
|
||||||
Function: dto.FunctionResponse{
|
|
||||||
Name: claudeResponse.ContentBlock.Name,
|
|
||||||
Arguments: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else if claudeResponse.Type == "content_block_delta" {
|
|
||||||
if claudeResponse.Delta != nil {
|
|
||||||
choice.Delta.Content = claudeResponse.Delta.Text
|
|
||||||
switch claudeResponse.Delta.Type {
|
|
||||||
case "input_json_delta":
|
|
||||||
tools = append(tools, dto.ToolCallResponse{
|
|
||||||
Type: "function",
|
|
||||||
Index: common.GetPointer(fcIdx),
|
|
||||||
Function: dto.FunctionResponse{
|
|
||||||
Arguments: *claudeResponse.Delta.PartialJson,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
case "signature_delta":
|
|
||||||
// 加密的不处理
|
|
||||||
signatureContent := "\n"
|
|
||||||
choice.Delta.ReasoningContent = &signatureContent
|
|
||||||
case "thinking_delta":
|
|
||||||
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if claudeResponse.Type == "message_delta" {
|
|
||||||
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
|
|
||||||
finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason)
|
|
||||||
if finishReason != "null" {
|
|
||||||
choice.FinishReason = &finishReason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//claudeUsage = &claudeResponse.Usage
|
|
||||||
} else if claudeResponse.Type == "message_stop" {
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(tools) > 0 {
|
|
||||||
choice.Delta.Content = nil // compatible with other OpenAI derivative applications, like LobeOpenAICompatibleFactory ...
|
|
||||||
choice.Delta.ToolCalls = tools
|
|
||||||
}
|
|
||||||
response.Choices = append(response.Choices, choice)
|
|
||||||
|
|
||||||
return &response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
|
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
|
||||||
choices := make([]dto.OpenAITextResponseChoice, 0)
|
return relayconvert.ResponseClaude2OpenAI(claudeResponse)
|
||||||
fullTextResponse := dto.OpenAITextResponse{
|
|
||||||
Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
|
|
||||||
Object: "chat.completion",
|
|
||||||
Created: common.GetTimestamp(),
|
|
||||||
}
|
|
||||||
var responseText string
|
|
||||||
var responseThinking string
|
|
||||||
if len(claudeResponse.Content) > 0 {
|
|
||||||
responseText = claudeResponse.Content[0].GetText()
|
|
||||||
if claudeResponse.Content[0].Thinking != nil {
|
|
||||||
responseThinking = *claudeResponse.Content[0].Thinking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tools := make([]dto.ToolCallResponse, 0)
|
|
||||||
thinkingContent := ""
|
|
||||||
|
|
||||||
fullTextResponse.Id = claudeResponse.Id
|
|
||||||
for _, message := range claudeResponse.Content {
|
|
||||||
switch message.Type {
|
|
||||||
case "tool_use":
|
|
||||||
args, _ := json.Marshal(message.Input)
|
|
||||||
tools = append(tools, dto.ToolCallResponse{
|
|
||||||
ID: message.Id,
|
|
||||||
Type: "function", // compatible with other OpenAI derivative applications
|
|
||||||
Function: dto.FunctionResponse{
|
|
||||||
Name: message.Name,
|
|
||||||
Arguments: string(args),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
case "thinking":
|
|
||||||
// 加密的不管, 只输出明文的推理过程
|
|
||||||
if message.Thinking != nil {
|
|
||||||
thinkingContent = *message.Thinking
|
|
||||||
}
|
|
||||||
case "text":
|
|
||||||
responseText = message.GetText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
choice := dto.OpenAITextResponseChoice{
|
|
||||||
Index: 0,
|
|
||||||
Message: dto.Message{
|
|
||||||
Role: "assistant",
|
|
||||||
},
|
|
||||||
FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason),
|
|
||||||
}
|
|
||||||
choice.SetStringContent(responseText)
|
|
||||||
if len(responseThinking) > 0 {
|
|
||||||
choice.ReasoningContent = &responseThinking
|
|
||||||
}
|
|
||||||
if len(tools) > 0 {
|
|
||||||
choice.Message.SetToolCalls(tools)
|
|
||||||
}
|
|
||||||
if thinkingContent != "" {
|
|
||||||
choice.Message.ReasoningContent = &thinkingContent
|
|
||||||
}
|
|
||||||
fullTextResponse.Model = claudeResponse.Model
|
|
||||||
choices = append(choices, choice)
|
|
||||||
fullTextResponse.Choices = choices
|
|
||||||
return &fullTextResponse
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClaudeResponseInfo struct {
|
type ClaudeResponseInfo = relayconvert.ClaudeResponseInfo
|
||||||
ResponseId string
|
|
||||||
Created int64
|
|
||||||
Model string
|
|
||||||
ResponseText strings.Builder
|
|
||||||
Usage *dto.Usage
|
|
||||||
Done bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
|
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
|
||||||
if usage == nil {
|
if usage == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
|
openAIUsage := relayconvert.UsageFromClaudeUsage(usage)
|
||||||
if splitCacheCreationTokens == 0 {
|
if openAIUsage == nil {
|
||||||
return usage.PromptTokensDetails.CachedCreationTokens
|
return 0
|
||||||
}
|
}
|
||||||
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
|
return openAIUsage.PromptTokens - usage.PromptTokens - usage.PromptTokensDetails.CachedTokens
|
||||||
return usage.PromptTokensDetails.CachedCreationTokens
|
|
||||||
}
|
|
||||||
return splitCacheCreationTokens
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||||
if usage == nil {
|
mapped := relayconvert.UsageFromClaudeUsage(usage)
|
||||||
|
if mapped == nil {
|
||||||
return dto.Usage{}
|
return dto.Usage{}
|
||||||
}
|
}
|
||||||
clone := *usage
|
return *mapped
|
||||||
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
|
|
||||||
usage.PromptTokensDetails.CachedCreationTokens,
|
|
||||||
usage.ClaudeCacheCreation5mTokens,
|
|
||||||
usage.ClaudeCacheCreation1hTokens,
|
|
||||||
)
|
|
||||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
|
||||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
|
||||||
clone.PromptTokens = totalInputTokens
|
|
||||||
clone.InputTokens = totalInputTokens
|
|
||||||
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
|
|
||||||
clone.UsageSemantic = "openai"
|
|
||||||
clone.UsageSource = "anthropic"
|
|
||||||
return clone
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
|
func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
|
||||||
usage := &dto.ClaudeUsage{}
|
return relayconvert.BuildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
|
||||||
if claudeResponse != nil && claudeResponse.Usage != nil {
|
|
||||||
*usage = *claudeResponse.Usage
|
|
||||||
}
|
|
||||||
|
|
||||||
if claudeInfo == nil || claudeInfo.Usage == nil {
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
|
|
||||||
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
|
|
||||||
usage.InputTokens = claudeInfo.Usage.PromptTokens
|
|
||||||
}
|
|
||||||
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
|
|
||||||
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
|
|
||||||
}
|
|
||||||
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
|
|
||||||
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
|
|
||||||
}
|
|
||||||
cacheCreation5m := 0
|
|
||||||
cacheCreation1h := 0
|
|
||||||
if usage.CacheCreation != nil {
|
|
||||||
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
|
|
||||||
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
|
|
||||||
} else {
|
|
||||||
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
|
|
||||||
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
|
|
||||||
}
|
|
||||||
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
|
|
||||||
usage.CacheCreationInputTokens,
|
|
||||||
cacheCreation5m,
|
|
||||||
cacheCreation1h,
|
|
||||||
)
|
|
||||||
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
|
|
||||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
|
|
||||||
}
|
|
||||||
if usage.CacheCreation != nil {
|
|
||||||
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
|
|
||||||
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
|
|
||||||
}
|
|
||||||
return usage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldSkipClaudeMessageDeltaUsagePatch(info *relaycommon.RelayInfo) bool {
|
func shouldSkipClaudeMessageDeltaUsagePatch(info *relaycommon.RelayInfo) bool {
|
||||||
@@ -680,109 +76,11 @@ func shouldSkipClaudeMessageDeltaUsagePatch(info *relaycommon.RelayInfo) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func patchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
|
func patchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
|
||||||
if data == "" || usage == nil {
|
return relayconvert.PatchClaudeMessageDeltaUsageData(data, usage)
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
|
|
||||||
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
|
|
||||||
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
|
|
||||||
|
|
||||||
if usage.CacheCreation != nil {
|
|
||||||
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation.Ephemeral5mInputTokens)
|
|
||||||
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation.Ephemeral1hInputTokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMessageDeltaUsageInt(data string, path string, localValue int) string {
|
|
||||||
if localValue <= 0 {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
upstreamValue := gjson.Get(data, path)
|
|
||||||
if upstreamValue.Exists() && upstreamValue.Int() > 0 {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
patchedData, err := sjson.Set(data, path, localValue)
|
|
||||||
if err != nil {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
return patchedData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
||||||
if claudeInfo == nil {
|
return relayconvert.FormatClaudeResponseInfo(claudeResponse, oaiResponse, claudeInfo)
|
||||||
return false
|
|
||||||
}
|
|
||||||
if claudeInfo.Usage == nil {
|
|
||||||
claudeInfo.Usage = &dto.Usage{}
|
|
||||||
}
|
|
||||||
if claudeResponse.Type == "message_start" {
|
|
||||||
if claudeResponse.Message != nil {
|
|
||||||
claudeInfo.ResponseId = claudeResponse.Message.Id
|
|
||||||
claudeInfo.Model = claudeResponse.Message.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
// message_start, 获取usage
|
|
||||||
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
|
|
||||||
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
|
|
||||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
|
||||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
|
|
||||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
|
|
||||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
|
|
||||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
|
|
||||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
|
|
||||||
}
|
|
||||||
} else if claudeResponse.Type == "content_block_delta" {
|
|
||||||
if claudeResponse.Delta != nil {
|
|
||||||
if claudeResponse.Delta.Text != nil {
|
|
||||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
|
|
||||||
}
|
|
||||||
if claudeResponse.Delta.Thinking != nil {
|
|
||||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if claudeResponse.Type == "message_delta" {
|
|
||||||
// 最终的usage获取
|
|
||||||
if claudeResponse.Usage != nil {
|
|
||||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
|
||||||
if claudeResponse.Usage.InputTokens > 0 {
|
|
||||||
// 不叠加,只取最新的
|
|
||||||
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
|
||||||
}
|
|
||||||
if claudeResponse.Usage.CacheReadInputTokens > 0 {
|
|
||||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
|
||||||
}
|
|
||||||
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
|
|
||||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
|
||||||
}
|
|
||||||
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
|
|
||||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
|
|
||||||
}
|
|
||||||
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
|
|
||||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
|
|
||||||
}
|
|
||||||
if claudeResponse.Usage.OutputTokens > 0 {
|
|
||||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
|
||||||
}
|
|
||||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断是否完整
|
|
||||||
claudeInfo.Done = true
|
|
||||||
} else if claudeResponse.Type == "content_block_start" {
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if oaiResponse != nil {
|
|
||||||
oaiResponse.Id = claudeInfo.ResponseId
|
|
||||||
oaiResponse.Created = claudeInfo.Created
|
|
||||||
oaiResponse.Model = claudeInfo.Model
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data string) *types.NewAPIError {
|
func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data string) *types.NewAPIError {
|
||||||
@@ -854,6 +152,9 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
|||||||
if claudeInfo.Usage != nil {
|
if claudeInfo.Usage != nil {
|
||||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||||
}
|
}
|
||||||
|
if claudeInfo.Usage != nil && claudeInfo.Usage.BillingUsage == nil {
|
||||||
|
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(buildMessageDeltaPatchUsage(nil, claudeInfo))
|
||||||
|
}
|
||||||
|
|
||||||
if info.RelayFormat == types.RelayFormatClaude {
|
if info.RelayFormat == types.RelayFormatClaude {
|
||||||
//
|
//
|
||||||
@@ -911,6 +212,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
|||||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||||
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
|
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
|
||||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||||
|
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
|
||||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
|
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
|
||||||
@@ -921,7 +223,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
|||||||
case types.RelayFormatOpenAI:
|
case types.RelayFormatOpenAI:
|
||||||
openaiResponse := ResponseClaude2OpenAI(&claudeResponse)
|
openaiResponse := ResponseClaude2OpenAI(&claudeResponse)
|
||||||
openaiResponse.Usage = buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
|
openaiResponse.Usage = buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
|
||||||
responseData, err = json.Marshal(openaiResponse)
|
responseData, err = common.Marshal(openaiResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
}
|
}
|
||||||
@@ -958,54 +260,3 @@ func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayI
|
|||||||
}
|
}
|
||||||
return claudeInfo.Usage, nil
|
return claudeInfo.Usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
|
|
||||||
var claudeToolChoice *dto.ClaudeToolChoice
|
|
||||||
|
|
||||||
// 处理 tool_choice 字符串值
|
|
||||||
if toolChoiceStr, ok := toolChoice.(string); ok {
|
|
||||||
switch toolChoiceStr {
|
|
||||||
case "auto":
|
|
||||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
|
||||||
Type: "auto",
|
|
||||||
}
|
|
||||||
case "required":
|
|
||||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
|
||||||
Type: "any",
|
|
||||||
}
|
|
||||||
case "none":
|
|
||||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
|
||||||
Type: "none",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
|
|
||||||
// 处理 tool_choice 对象值
|
|
||||||
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
|
|
||||||
if toolName, ok := function["name"].(string); ok {
|
|
||||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
|
||||||
Type: "tool",
|
|
||||||
Name: toolName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理 parallel_tool_calls
|
|
||||||
if parallelToolCalls != nil {
|
|
||||||
if claudeToolChoice == nil {
|
|
||||||
// 如果没有 tool_choice,但有 parallel_tool_calls,创建默认的 auto 类型
|
|
||||||
claudeToolChoice = &dto.ClaudeToolChoice{
|
|
||||||
Type: "auto",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anthropic schema: tool_choice.type=none does not accept extra fields.
|
|
||||||
// When tools are disabled, parallel_tool_calls is irrelevant, so we drop it.
|
|
||||||
if claudeToolChoice.Type != "none" {
|
|
||||||
// 如果 parallel_tool_calls 为 true,则 disable_parallel_tool_use 为 false
|
|
||||||
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return claudeToolChoice
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -41,7 +41,7 @@ func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
resp := service.ResponseOpenAI2Claude(&dto.OpenAITextResponse{
|
resp := relayconvert.ResponseOpenAI2Claude(&dto.OpenAITextResponse{
|
||||||
Id: "chatcmpl_1",
|
Id: "chatcmpl_1",
|
||||||
Model: "gpt-test",
|
Model: "gpt-test",
|
||||||
Choices: []dto.OpenAITextResponseChoice{
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
@@ -322,7 +322,7 @@ func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(
|
|||||||
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
|
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
|
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
|
||||||
request := dto.GeneralOpenAIRequest{
|
request := dto.GeneralOpenAIRequest{
|
||||||
Model: "claude-opus-4-8-high",
|
Model: "claude-opus-4-8-high",
|
||||||
Temperature: commonPointer(0.7),
|
Temperature: commonPointer(0.7),
|
||||||
@@ -336,7 +336,7 @@ func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *tes
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, request)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||||
require.NotNil(t, claudeRequest.Thinking)
|
require.NotNil(t, claudeRequest.Thinking)
|
||||||
@@ -348,7 +348,7 @@ func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *tes
|
|||||||
require.Nil(t, claudeRequest.TopK)
|
require.Nil(t, claudeRequest.TopK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
|
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
|
||||||
request := dto.GeneralOpenAIRequest{
|
request := dto.GeneralOpenAIRequest{
|
||||||
Model: "claude-opus-4-8-thinking",
|
Model: "claude-opus-4-8-thinking",
|
||||||
Temperature: commonPointer(0.7),
|
Temperature: commonPointer(0.7),
|
||||||
@@ -362,7 +362,7 @@ func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, request)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||||
require.NotNil(t, claudeRequest.Thinking)
|
require.NotNil(t, claudeRequest.Thinking)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/relay/channel"
|
"github.com/QuantumNous/new-api/relay/channel"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/constant"
|
"github.com/QuantumNous/new-api/relay/constant"
|
||||||
"github.com/QuantumNous/new-api/service/relayconvert"
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
@@ -45,12 +44,15 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
|
||||||
adaptor := openai.Adaptor{}
|
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, req)
|
||||||
oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.ConvertOpenAIRequest(c, info, oaiReq.(*dto.GeneralOpenAIRequest))
|
geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return geminiRequest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||||
@@ -181,13 +183,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
if request == nil {
|
if request == nil {
|
||||||
return nil, errors.New("request is nil")
|
return nil, errors.New("request is nil")
|
||||||
}
|
}
|
||||||
|
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, request)
|
||||||
geminiRequest, err := CovertOpenAI2Gemini(c, *request, info)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return result.Value, nil
|
||||||
return geminiRequest, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
|
||||||
@@ -239,17 +239,15 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||||
request, err := preprocessGeminiOpenAIResponsesRequest(request)
|
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, &request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
chatRequest, err := relayconvert.ResponsesRequestToChatCompletionsRequest(&request)
|
if !ok {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return geminiRequest, nil
|
||||||
return a.ConvertOpenAIRequest(c, info, chatRequest)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
|||||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算使用量(基于 UsageMetadata)
|
// 计算使用量(优先上游 UsageMetadata,缺失时本地估算并保留 Gemini 计费语义)
|
||||||
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||||
|
|
||||||
service.IOCopyBytesGracefully(c, resp, responseBody)
|
service.IOCopyBytesGracefully(c, resp, responseBody)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -331,3 +331,186 @@ func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMiss
|
|||||||
require.Equal(t, 100, usage.CompletionTokens)
|
require.Equal(t, 100, usage.CompletionTokens)
|
||||||
require.Equal(t, 110, usage.TotalTokens)
|
require.Equal(t, 110, usage.TotalTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeminiChatHandlerMissingUsageMetadataBuildsEstimatedBillingUsage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatGemini,
|
||||||
|
OriginModelName: "gemini-3-flash-preview",
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-3-flash-preview",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info.SetEstimatePromptTokens(20)
|
||||||
|
|
||||||
|
body := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}]}`)
|
||||||
|
resp := &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||||
|
require.Nil(t, newAPIError)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
require.Equal(t, 20, usage.PromptTokens)
|
||||||
|
require.NotNil(t, usage.BillingUsage)
|
||||||
|
require.True(t, usage.BillingUsage.Estimated)
|
||||||
|
require.Equal(t, dto.BillingUsageSourceGeminiChat, usage.BillingUsage.Source)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticGemini, usage.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
require.Equal(t, usage.PromptTokens, usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiStreamHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||||
|
|
||||||
|
oldStreamingTimeout := constant.StreamingTimeout
|
||||||
|
constant.StreamingTimeout = 300
|
||||||
|
t.Cleanup(func() {
|
||||||
|
constant.StreamingTimeout = oldStreamingTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
OriginModelName: "gemini-3-flash-preview",
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-3-flash-preview",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info.SetEstimatePromptTokens(20)
|
||||||
|
|
||||||
|
// Simulates a client aborting the stream before the final chunk: text was
|
||||||
|
// streamed but the last observed usageMetadata only carries prompt tokens.
|
||||||
|
chunk := dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{
|
||||||
|
{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: "partial streamed answer before disconnect"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 151,
|
||||||
|
TotalTokenCount: 151,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkData, err := common.Marshal(chunk)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
streamBody := []byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n")
|
||||||
|
resp := &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
require.Nil(t, newAPIError)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
require.Equal(t, 151, usage.PromptTokens)
|
||||||
|
require.Greater(t, usage.CompletionTokens, 0)
|
||||||
|
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
|
||||||
|
require.NotNil(t, usage.BillingUsage)
|
||||||
|
require.True(t, usage.BillingUsage.Estimated)
|
||||||
|
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiChatHandlerPromptOnlyUsageMetadataEstimatesCompletionTokens(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatGemini,
|
||||||
|
OriginModelName: "gemini-3-flash-preview",
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-3-flash-preview",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{
|
||||||
|
{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: "answer text without candidate token count"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 151,
|
||||||
|
TotalTokenCount: 151,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := common.Marshal(payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(body)),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, newAPIError := GeminiChatHandler(c, info, resp)
|
||||||
|
require.Nil(t, newAPIError)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
require.Equal(t, 151, usage.PromptTokens)
|
||||||
|
require.Greater(t, usage.CompletionTokens, 0)
|
||||||
|
require.Equal(t, usage.PromptTokens+usage.CompletionTokens, usage.TotalTokens)
|
||||||
|
require.NotNil(t, usage.BillingUsage)
|
||||||
|
require.True(t, usage.BillingUsage.Estimated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiStreamHandlerEmptyUsageMetadataBuildsEstimatedBillingUsage(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||||
|
|
||||||
|
oldStreamingTimeout := constant.StreamingTimeout
|
||||||
|
constant.StreamingTimeout = 300
|
||||||
|
t.Cleanup(func() {
|
||||||
|
constant.StreamingTimeout = oldStreamingTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
OriginModelName: "gemini-3-flash-preview",
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-3-flash-preview",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info.SetEstimatePromptTokens(20)
|
||||||
|
|
||||||
|
streamBody := []byte("data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"partial\"}]}}],\"usageMetadata\":{}}\n" + "data: [DONE]\n")
|
||||||
|
resp := &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader(streamBody)),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, newAPIError := geminiStreamHandler(c, info, resp, func(_ string, _ *dto.GeminiChatResponse) bool {
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
require.Nil(t, newAPIError)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
require.Equal(t, 20, usage.PromptTokens)
|
||||||
|
require.NotNil(t, usage.BillingUsage)
|
||||||
|
require.True(t, usage.BillingUsage.Estimated)
|
||||||
|
require.Equal(t, dto.BillingUsageSourceGeminiChat, usage.BillingUsage.Source)
|
||||||
|
require.NotNil(t, usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
require.Equal(t, usage.PromptTokens, usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
require.Equal(t, usage.CompletionTokens, usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
require.True(t, common.GetContextKeyBool(c, constant.ContextKeyLocalCountTokens))
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
|||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
if len(geminiResponse.Candidates) == 0 {
|
if len(geminiResponse.Candidates) == 0 {
|
||||||
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||||
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
|
||||||
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
|
||||||
return &usage, types.NewOpenAIError(
|
return &usage, types.NewOpenAIError(
|
||||||
@@ -51,13 +51,21 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
|||||||
|
|
||||||
chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
|
chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
|
||||||
chatResp.Model = info.UpstreamModelName
|
chatResp.Model = info.UpstreamModelName
|
||||||
usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
|
if responseID := helper.GetResponseID(c); responseID != "" {
|
||||||
|
chatResp.Id = responseID
|
||||||
|
}
|
||||||
|
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
|
||||||
chatResp.Usage = usage
|
chatResp.Usage = usage
|
||||||
|
|
||||||
responsesResp, responsesUsage, err := service.ChatCompletionsResponseToResponsesResponse(chatResp, helper.GetResponseID(c))
|
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, chatResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
responsesResp, ok := convertResult.Value.(*dto.OpenAIResponsesResponse)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
responsesUsage := convertResult.Usage
|
||||||
if responsesUsage == nil || responsesUsage.TotalTokens == 0 {
|
if responsesUsage == nil || responsesUsage.TotalTokens == 0 {
|
||||||
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
|
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
|
||||||
}
|
}
|
||||||
@@ -73,8 +81,14 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
|
|||||||
func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
|
||||||
responseID := helper.GetResponseID(c)
|
responseID := helper.GetResponseID(c)
|
||||||
created := common.GetTimestamp()
|
created := common.GetTimestamp()
|
||||||
state := relayconvert.NewChatToResponsesStreamState(responseID, info.UpstreamModelName)
|
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
|
||||||
state.Created = created
|
ID: responseID,
|
||||||
|
Model: info.UpstreamModelName,
|
||||||
|
Created: created,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
finishReason := constant.FinishReasonStop
|
finishReason := constant.FinishReasonStop
|
||||||
toolCallIndexByChoice := make(map[int]map[string]int)
|
toolCallIndexByChoice := make(map[int]map[string]int)
|
||||||
nextToolCallIndexByChoice := make(map[int]int)
|
nextToolCallIndexByChoice := make(map[int]int)
|
||||||
@@ -90,12 +104,17 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
|
sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
|
||||||
events, err := relayconvert.ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, chunk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, event := range events {
|
for _, result := range results {
|
||||||
|
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||||
|
if !ok {
|
||||||
|
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if !sendEvent(event) {
|
if !sendEvent(event) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -103,7 +122,7 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
usage, err := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
|
usage, streamAPIError := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
|
||||||
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
|
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
|
||||||
response.Id = responseID
|
response.Id = responseID
|
||||||
response.Created = created
|
response.Created = created
|
||||||
@@ -143,17 +162,25 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
if err != nil {
|
if streamAPIError != nil {
|
||||||
return usage, err
|
return usage, streamAPIError
|
||||||
}
|
}
|
||||||
if streamErr != nil {
|
if streamErr != nil {
|
||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if usage != nil {
|
if usage != nil {
|
||||||
state.Usage = relayconvert.UsageFromChatUsage(usage)
|
state.SetUsage(usage)
|
||||||
}
|
}
|
||||||
for _, event := range relayconvert.FinalizeChatCompletionsStreamToResponses(state) {
|
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
for _, result := range finalResults {
|
||||||
|
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
if !sendEvent(event) {
|
if !sendEvent(event) {
|
||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,14 @@ type Adaptor struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
||||||
// 使用 service.GeminiToOpenAIRequest 转换请求格式
|
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
|
||||||
openaiRequest, err := service.GeminiToOpenAIRequest(request, info)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
openaiRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
return a.ConvertOpenAIRequest(c, info, openaiRequest)
|
return a.ConvertOpenAIRequest(c, info, openaiRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +64,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
// println(fmt.Sprintf("failed to save request body to file: %v", err))
|
// println(fmt.Sprintf("failed to save request body to file: %v", err))
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
aiRequest, err := service.ClaudeToOpenAIRequest(*request, info)
|
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
aiRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
//if common.DebugEnabled {
|
//if common.DebugEnabled {
|
||||||
// println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest)))
|
// println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest)))
|
||||||
// // Save request body to file for debugging
|
// // Save request body to file for debugging
|
||||||
|
|||||||
@@ -41,11 +41,18 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
|||||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
chatId := helper.GetResponseID(c)
|
chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, &responsesResp)
|
||||||
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if chatID := helper.GetResponseID(c); chatID != "" {
|
||||||
|
chatResp.Id = chatID
|
||||||
|
}
|
||||||
|
usage := chatResult.Usage
|
||||||
|
|
||||||
if usage == nil || usage.TotalTokens == 0 {
|
if usage == nil || usage.TotalTokens == 0 {
|
||||||
text := service.ExtractOutputTextFromResponses(&responsesResp)
|
text := service.ExtractOutputTextFromResponses(&responsesResp)
|
||||||
@@ -53,17 +60,15 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
|||||||
chatResp.Usage = *usage
|
chatResp.Usage = *usage
|
||||||
}
|
}
|
||||||
|
|
||||||
var responseBody []byte
|
responseValue := any(chatResp)
|
||||||
switch info.RelayFormat {
|
if info.RelayFormat != types.RelayFormatOpenAI {
|
||||||
case types.RelayFormatClaude:
|
targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
|
||||||
claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
|
if err != nil {
|
||||||
responseBody, err = common.Marshal(claudeResp)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
case types.RelayFormatGemini:
|
}
|
||||||
geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
|
responseValue = targetResult.Value
|
||||||
responseBody, err = common.Marshal(geminiResp)
|
|
||||||
default:
|
|
||||||
responseBody, err = common.Marshal(chatResp)
|
|
||||||
}
|
}
|
||||||
|
responseBody, err := common.Marshal(responseValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -145,28 +150,33 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
|
|||||||
}
|
}
|
||||||
accumulator.SupplementResponseOutput(finalResponse)
|
accumulator.SupplementResponseOutput(finalResponse)
|
||||||
|
|
||||||
chatId := helper.GetResponseID(c)
|
chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, finalResponse)
|
||||||
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(finalResponse, chatId)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
if chatID := helper.GetResponseID(c); chatID != "" {
|
||||||
|
chatResp.Id = chatID
|
||||||
|
}
|
||||||
|
usage := chatResult.Usage
|
||||||
if usage == nil || usage.TotalTokens == 0 {
|
if usage == nil || usage.TotalTokens == 0 {
|
||||||
text := service.ExtractOutputTextFromResponses(finalResponse)
|
text := service.ExtractOutputTextFromResponses(finalResponse)
|
||||||
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||||
chatResp.Usage = *usage
|
chatResp.Usage = *usage
|
||||||
}
|
}
|
||||||
|
|
||||||
var responseBody []byte
|
responseValue := any(chatResp)
|
||||||
switch info.RelayFormat {
|
if info.RelayFormat != types.RelayFormatOpenAI {
|
||||||
case types.RelayFormatClaude:
|
targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
|
||||||
claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
|
if err != nil {
|
||||||
responseBody, err = common.Marshal(claudeResp)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
case types.RelayFormatGemini:
|
}
|
||||||
geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
|
responseValue = targetResult.Value
|
||||||
responseBody, err = common.Marshal(geminiResp)
|
|
||||||
default:
|
|
||||||
responseBody, err = common.Marshal(chatResp)
|
|
||||||
}
|
}
|
||||||
|
responseBody, err := common.Marshal(responseValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
@@ -184,37 +194,77 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
|
|
||||||
responseId := helper.GetResponseID(c)
|
responseId := helper.GetResponseID(c)
|
||||||
createAt := time.Now().Unix()
|
createAt := time.Now().Unix()
|
||||||
state := relayconvert.NewResponsesToChatStreamState(info.UpstreamModelName, false)
|
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAIResponses, info.RelayFormat, relayconvert.ResponseStreamOptions{
|
||||||
state.ID = responseId
|
ID: responseId,
|
||||||
state.Created = createAt
|
Model: info.UpstreamModelName,
|
||||||
|
Created: createAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
streamErr := (*types.NewAPIError)(nil)
|
streamErr := (*types.NewAPIError)(nil)
|
||||||
|
|
||||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
|
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
|
||||||
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
|
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendChatChunk := func(chunk dto.ChatCompletionsStreamResponse) bool {
|
sendGeminiResponse := func(geminiResponse *dto.GeminiChatResponse) bool {
|
||||||
if len(chunk.Choices) == 0 && chunk.Usage == nil {
|
if geminiResponse == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if info.RelayFormat == types.RelayFormatOpenAI {
|
geminiResponseStr, err := common.Marshal(geminiResponse)
|
||||||
if err := helper.ObjectData(c, &chunk); err != nil {
|
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
chunkData, err := common.Marshal(&chunk)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err := HandleStreamFormat(c, info, string(chunkData), false, false); err != nil {
|
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
_ = helper.FlushWriter(c)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
sendStreamResult := func(result relayconvert.ResponseResult) bool {
|
||||||
|
switch value := result.Value.(type) {
|
||||||
|
case dto.ChatCompletionsStreamResponse:
|
||||||
|
if len(value.Choices) == 0 && value.Usage == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := helper.ObjectData(c, &value); err != nil {
|
||||||
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case *dto.ChatCompletionsStreamResponse:
|
||||||
|
if value == nil || (len(value.Choices) == 0 && value.Usage == nil) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := helper.ObjectData(c, value); err != nil {
|
||||||
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case dto.ClaudeResponse:
|
||||||
|
if err := helper.ClaudeData(c, value); err != nil {
|
||||||
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case *dto.ClaudeResponse:
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := helper.ClaudeData(c, *value); err != nil {
|
||||||
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case dto.GeminiChatResponse:
|
||||||
|
return sendGeminiResponse(&value)
|
||||||
|
case *dto.GeminiChatResponse:
|
||||||
|
return sendGeminiResponse(value)
|
||||||
|
default:
|
||||||
|
streamErr = types.NewOpenAIError(fmt.Errorf("unsupported converted stream response type %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
|
||||||
@@ -243,14 +293,14 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks, err := relayconvert.ResponsesStreamEventToChatChunks(&streamResp, state)
|
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &streamResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
sr.Stop(streamErr)
|
sr.Stop(streamErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, chunk := range chunks {
|
for _, result := range results {
|
||||||
if !sendChatChunk(chunk) {
|
if !sendStreamResult(result) {
|
||||||
sr.Stop(streamErr)
|
sr.Stop(streamErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -261,22 +311,26 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
|
|
||||||
usage := state.Usage
|
usage := state.Usage()
|
||||||
if usage.TotalTokens == 0 {
|
if usage == nil || usage.TotalTokens == 0 {
|
||||||
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||||
state.Usage = usage
|
state.SetUsage(usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
|
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
|
||||||
info.ClaudeConvertInfo.Usage = usage
|
info.ClaudeConvertInfo.Usage = usage
|
||||||
}
|
}
|
||||||
for _, chunk := range relayconvert.FinalizeResponsesToChatStream(state) {
|
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
|
||||||
if !sendChatChunk(chunk) {
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
for _, result := range finalResults {
|
||||||
|
if !sendStreamResult(result) {
|
||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
|
if info.RelayFormat == types.RelayFormatOpenAI && info.ShouldIncludeUsage && usage != nil {
|
||||||
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, state.Created, state.Model, *usage)); err != nil {
|
if err := helper.ObjectData(c, helper.GenerateFinalUsageResponse(responseId, createAt, info.UpstreamModelName, *usage)); err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package openai
|
package openai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||||
"github.com/QuantumNous/new-api/relay/helper"
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
@@ -41,7 +43,14 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
|||||||
if streamResponse.Usage != nil {
|
if streamResponse.Usage != nil {
|
||||||
info.ClaudeConvertInfo.Usage = streamResponse.Usage
|
info.ClaudeConvertInfo.Usage = streamResponse.Usage
|
||||||
}
|
}
|
||||||
claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info)
|
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
claudeResponses, ok := result.Value.([]*dto.ClaudeResponse)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("expected Claude stream responses, got %T", result.Value)
|
||||||
|
}
|
||||||
for _, resp := range claudeResponses {
|
for _, resp := range claudeResponses {
|
||||||
helper.ClaudeData(c, *resp)
|
helper.ClaudeData(c, *resp)
|
||||||
}
|
}
|
||||||
@@ -55,7 +64,14 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info)
|
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
|
||||||
|
}
|
||||||
|
|
||||||
// 如果返回 nil,表示没有实际内容,跳过发送
|
// 如果返回 nil,表示没有实际内容,跳过发送
|
||||||
if geminiResponse == nil {
|
if geminiResponse == nil {
|
||||||
@@ -165,7 +181,16 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
|||||||
|
|
||||||
info.ClaudeConvertInfo.Usage = usage
|
info.ClaudeConvertInfo.Usage = usage
|
||||||
|
|
||||||
claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info)
|
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("error converting Claude stream response: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claudeResponses, ok := result.Value.([]*dto.ClaudeResponse)
|
||||||
|
if !ok {
|
||||||
|
common.SysLog(fmt.Sprintf("expected Claude stream responses, got %T", result.Value))
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, resp := range claudeResponses {
|
for _, resp := range claudeResponses {
|
||||||
_ = helper.ClaudeData(c, *resp)
|
_ = helper.ClaudeData(c, *resp)
|
||||||
}
|
}
|
||||||
@@ -183,7 +208,16 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
|||||||
// 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null
|
// 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null
|
||||||
// 暂不知是否有程序会不兼容。
|
// 暂不知是否有程序会不兼容。
|
||||||
|
|
||||||
geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info)
|
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("error converting Gemini stream response: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
|
||||||
|
if !ok {
|
||||||
|
common.SysLog(fmt.Sprintf("expected Gemini stream response, got %T", result.Value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// openai 流响应开头的空数据
|
// openai 流响应开头的空数据
|
||||||
if geminiResponse == nil {
|
if geminiResponse == nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/helper"
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -271,15 +272,21 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case types.RelayFormatClaude:
|
case types.RelayFormatClaude:
|
||||||
claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info)
|
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
|
||||||
claudeRespStr, err := common.Marshal(claudeResp)
|
if err != nil {
|
||||||
|
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
claudeRespStr, err := common.Marshal(convertResult.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
}
|
}
|
||||||
responseBody = claudeRespStr
|
responseBody = claudeRespStr
|
||||||
case types.RelayFormatGemini:
|
case types.RelayFormatGemini:
|
||||||
geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info)
|
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
|
||||||
geminiRespStr, err := common.Marshal(geminiResp)
|
if err != nil {
|
||||||
|
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
geminiRespStr, err := common.Marshal(convertResult.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
|
|||||||
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
responseID := helper.GetResponseID(c)
|
if responseID := helper.GetResponseID(c); responseID != "" {
|
||||||
responsesResp, usage, err := service.ChatCompletionsResponseToResponsesResponse(&chatResp, responseID)
|
chatResp.Id = responseID
|
||||||
|
}
|
||||||
|
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
responsesResp, ok := convertResult.Value.(*dto.OpenAIResponsesResponse)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
usage := convertResult.Usage
|
||||||
if usage == nil || usage.TotalTokens == 0 {
|
if usage == nil || usage.TotalTokens == 0 {
|
||||||
text := service.ExtractOutputTextFromResponses(responsesResp)
|
text := service.ExtractOutputTextFromResponses(responsesResp)
|
||||||
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||||
@@ -62,7 +69,13 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
defer service.CloseResponseBodyGracefully(resp)
|
defer service.CloseResponseBodyGracefully(resp)
|
||||||
|
|
||||||
responseID := helper.GetResponseID(c)
|
responseID := helper.GetResponseID(c)
|
||||||
state := relayconvert.NewChatToResponsesStreamState(responseID, info.UpstreamModelName)
|
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
|
||||||
|
ID: responseID,
|
||||||
|
Model: info.UpstreamModelName,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
streamErr := (*types.NewAPIError)(nil)
|
streamErr := (*types.NewAPIError)(nil)
|
||||||
|
|
||||||
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
|
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
|
||||||
@@ -97,13 +110,19 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
events, err := relayconvert.ChatCompletionsStreamChunkToResponsesEvents(&chunk, state)
|
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &chunk)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
sr.Stop(streamErr)
|
sr.Stop(streamErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, event := range events {
|
for _, result := range results {
|
||||||
|
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||||
|
if !ok {
|
||||||
|
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
sr.Stop(streamErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !sendEvent(event) {
|
if !sendEvent(event) {
|
||||||
sr.Stop(streamErr)
|
sr.Stop(streamErr)
|
||||||
return
|
return
|
||||||
@@ -115,13 +134,21 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
|
|||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
|
|
||||||
usage := state.Usage
|
usage := state.Usage()
|
||||||
if usage == nil || usage.TotalTokens == 0 {
|
if usage == nil || usage.TotalTokens == 0 {
|
||||||
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||||
state.Usage = relayconvert.UsageFromChatUsage(usage)
|
state.SetUsage(usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, event := range relayconvert.FinalizeChatCompletionsStreamToResponses(state) {
|
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
for _, result := range finalResults {
|
||||||
|
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
if !sendEvent(event) {
|
if !sendEvent(event) {
|
||||||
return nil, streamErr
|
return nil, streamErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package vertex
|
package vertex
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/constant"
|
"github.com/QuantumNous/new-api/relay/constant"
|
||||||
|
"github.com/QuantumNous/new-api/service"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
"github.com/QuantumNous/new-api/setting/reasoning"
|
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
@@ -267,7 +267,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
}
|
}
|
||||||
if len(request.ExtraBody) > 0 {
|
if len(request.ExtraBody) > 0 {
|
||||||
var extra map[string]any
|
var extra map[string]any
|
||||||
if err := json.Unmarshal(request.ExtraBody, &extra); err == nil {
|
if err := common.Unmarshal(request.ExtraBody, &extra); err == nil {
|
||||||
if n, ok := extra["n"].(float64); ok && n > 0 {
|
if n, ok := extra["n"].(float64); ok && n > 0 {
|
||||||
imgReq.N = lo.ToPtr(uint(n))
|
imgReq.N = lo.ToPtr(uint(n))
|
||||||
}
|
}
|
||||||
@@ -289,19 +289,27 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
|||||||
return a.ConvertImageRequest(c, info, imgReq)
|
return a.ConvertImageRequest(c, info, imgReq)
|
||||||
}
|
}
|
||||||
if a.RequestMode == RequestModeClaude {
|
if a.RequestMode == RequestModeClaude {
|
||||||
claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request)
|
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", result.Value)
|
||||||
|
}
|
||||||
vertexClaudeReq := copyRequest(claudeReq, anthropicVersion)
|
vertexClaudeReq := copyRequest(claudeReq, anthropicVersion)
|
||||||
c.Set("request_model", claudeReq.Model)
|
c.Set("request_model", claudeReq.Model)
|
||||||
info.UpstreamModelName = claudeReq.Model
|
info.UpstreamModelName = claudeReq.Model
|
||||||
return vertexClaudeReq, nil
|
return vertexClaudeReq, nil
|
||||||
} else if a.RequestMode == RequestModeGemini {
|
} else if a.RequestMode == RequestModeGemini {
|
||||||
geminiRequest, err := gemini.CovertOpenAI2Gemini(c, *request, info)
|
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
|
||||||
|
}
|
||||||
c.Set("request_model", request.Model)
|
c.Set("request_model", request.Model)
|
||||||
return geminiRequest, nil
|
return geminiRequest, nil
|
||||||
} else if a.RequestMode == RequestModeOpenSource {
|
} else if a.RequestMode == RequestModeOpenSource {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package relay
|
package relay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -92,11 +93,14 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
|||||||
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
|
|
||||||
responsesReq, err := service.ChatCompletionsRequestToResponsesRequest(&overriddenChatReq)
|
result, err := service.ConvertRequestVia(c, info, &overriddenChatReq, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
info.AppendRequestConversion(types.RelayFormatOpenAIResponses)
|
responsesReq, ok := result.Value.(*dto.OpenAIResponsesRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewError(fmt.Errorf("expected OpenAI responses request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
|
||||||
savedRelayMode := info.RelayMode
|
savedRelayMode := info.RelayMode
|
||||||
savedRequestURLPath := info.RequestURLPath
|
savedRequestURLPath := info.RequestURLPath
|
||||||
|
|||||||
@@ -135,10 +135,14 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
|
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
|
||||||
!info.ChannelSetting.PassThroughBodyEnabled &&
|
!info.ChannelSetting.PassThroughBodyEnabled &&
|
||||||
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
|
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
|
||||||
openAIRequest, convErr := service.ClaudeToOpenAIRequest(*request, info)
|
result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
|
||||||
if convErr != nil {
|
if convErr != nil {
|
||||||
return types.NewError(convErr, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
return types.NewError(convErr, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||||
}
|
}
|
||||||
|
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return types.NewError(fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||||
|
}
|
||||||
|
|
||||||
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, openAIRequest)
|
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, openAIRequest)
|
||||||
if newApiErr != nil {
|
if newApiErr != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package common
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -36,6 +37,67 @@ func GetFullRequestURL(baseURL string, requestURL string, channelType int) strin
|
|||||||
return fullRequestURL
|
return fullRequestURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SanitizeURLForLog(rawURL string) string {
|
||||||
|
if rawURL == "" {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
query := parsedURL.Query()
|
||||||
|
if len(query) == 0 {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for key := range query {
|
||||||
|
if isSensitiveURLQueryKey(key) {
|
||||||
|
query.Set(key, "***masked***")
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return rawURL
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL.RawQuery = query.Encode()
|
||||||
|
return parsedURL.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSensitiveURLQueryKey(key string) bool {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||||
|
switch normalized {
|
||||||
|
case "key",
|
||||||
|
"api_key",
|
||||||
|
"api-key",
|
||||||
|
"apikey",
|
||||||
|
"x-api-key",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"id_token",
|
||||||
|
"token",
|
||||||
|
"authorization",
|
||||||
|
"auth",
|
||||||
|
"client_secret",
|
||||||
|
"secret",
|
||||||
|
"password",
|
||||||
|
"passwd",
|
||||||
|
"signature",
|
||||||
|
"sig",
|
||||||
|
"awsaccesskeyid",
|
||||||
|
"x-amz-credential",
|
||||||
|
"x-amz-security-token",
|
||||||
|
"x-amz-signature":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(normalized, "token") ||
|
||||||
|
strings.Contains(normalized, "secret") ||
|
||||||
|
strings.Contains(normalized, "signature")
|
||||||
|
}
|
||||||
|
|
||||||
func GetAPIVersion(c *gin.Context) string {
|
func GetAPIVersion(c *gin.Context) string {
|
||||||
query := c.Request.URL.Query()
|
query := c.Request.URL.Query()
|
||||||
apiVersion := query.Get("api-version")
|
apiVersion := query.Get("api-version")
|
||||||
|
|||||||
@@ -3,14 +3,59 @@ package common
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSanitizeURLForLogMasksSensitiveQueryValues(t *testing.T) {
|
||||||
|
rawURL := "https://example.test/v1beta/models/gemini:streamGenerateContent?alt=sse&key=sk-secret&access_token=ya29-secret&api-version=2024-02-01"
|
||||||
|
|
||||||
|
got := SanitizeURLForLog(rawURL)
|
||||||
|
|
||||||
|
assert.NotContains(t, got, "sk-secret")
|
||||||
|
assert.NotContains(t, got, "ya29-secret")
|
||||||
|
parsedURL, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
query := parsedURL.Query()
|
||||||
|
assert.Equal(t, "***masked***", query.Get("key"))
|
||||||
|
assert.Equal(t, "***masked***", query.Get("access_token"))
|
||||||
|
assert.Equal(t, "sse", query.Get("alt"))
|
||||||
|
assert.Equal(t, "2024-02-01", query.Get("api-version"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeURLForLogMasksAWSAndSecretLikeQueryKeys(t *testing.T) {
|
||||||
|
rawURL := "https://example.test/path?X-Amz-Credential=credential&X-Amz-Signature=signature&session_token=session&client_secret=secret&model=gpt-test"
|
||||||
|
|
||||||
|
got := SanitizeURLForLog(rawURL)
|
||||||
|
|
||||||
|
assert.NotContains(t, got, "X-Amz-Credential=credential")
|
||||||
|
assert.NotContains(t, got, "X-Amz-Signature=signature")
|
||||||
|
assert.NotContains(t, got, "session_token=session")
|
||||||
|
assert.NotContains(t, got, "client_secret=secret")
|
||||||
|
parsedURL, err := url.Parse(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
query := parsedURL.Query()
|
||||||
|
assert.Equal(t, "***masked***", query.Get("X-Amz-Credential"))
|
||||||
|
assert.Equal(t, "***masked***", query.Get("X-Amz-Signature"))
|
||||||
|
assert.Equal(t, "***masked***", query.Get("session_token"))
|
||||||
|
assert.Equal(t, "***masked***", query.Get("client_secret"))
|
||||||
|
assert.Equal(t, "gpt-test", query.Get("model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeURLForLogKeepsURLWithoutSensitiveQuery(t *testing.T) {
|
||||||
|
rawURL := "https://example.test/v1/chat/completions?api-version=2024-02-01&alt=sse"
|
||||||
|
|
||||||
|
got := SanitizeURLForLog(rawURL)
|
||||||
|
|
||||||
|
assert.Equal(t, rawURL, got)
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateMultipartDirectNormalizesImageField(t *testing.T) {
|
func TestValidateMultipartDirectNormalizesImageField(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
body := strings.NewReader(`{"model":"wan2.7-i2v","prompt":"animate","image":" https://example.com/first.png "}`)
|
body := strings.NewReader(`{"model":"wan2.7-i2v","prompt":"animate","image":" https://example.com/first.png "}`)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/QuantumNous/new-api/logger"
|
"github.com/QuantumNous/new-api/logger"
|
||||||
"github.com/QuantumNous/new-api/relay/channel/gemini"
|
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/relay/helper"
|
"github.com/QuantumNous/new-api/relay/helper"
|
||||||
"github.com/QuantumNous/new-api/service"
|
"github.com/QuantumNous/new-api/service"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if request.GenerationConfig.ThinkingConfig == nil {
|
if request.GenerationConfig.ThinkingConfig == nil {
|
||||||
gemini.ThinkingAdaptor(request, info)
|
relayconvert.ApplyGeminiThinkingConfig(request, info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
usageBillingPathLocal = "local"
|
||||||
|
usageBillingPathUpstream = "upstream"
|
||||||
|
usageBillingPathOpenAI = "billing-usage-openai"
|
||||||
|
usageBillingPathOpenAIEstimated = "billing-usage-openai-estimated"
|
||||||
|
usageBillingPathAnthropic = "billing-usage-anthropic"
|
||||||
|
usageBillingPathAnthropicEstimated = "billing-usage-anthropic-estimated"
|
||||||
|
usageBillingPathGemini = "billing-usage-gemini"
|
||||||
|
usageBillingPathGeminiEstimated = "billing-usage-gemini-estimated"
|
||||||
|
)
|
||||||
|
|
||||||
|
func effectiveBillingUsage(usage *dto.Usage) *dto.Usage {
|
||||||
|
if billingUsage, ok := usageFromBillingUsage(usage); ok {
|
||||||
|
return billingUsage
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string {
|
||||||
|
if isLocalCountTokens {
|
||||||
|
return usageBillingPathLocal
|
||||||
|
}
|
||||||
|
if usage == nil || usage.BillingUsage == nil {
|
||||||
|
return usageBillingPathUpstream
|
||||||
|
}
|
||||||
|
source := strings.TrimSpace(usage.BillingUsage.Source)
|
||||||
|
semantic := strings.TrimSpace(usage.BillingUsage.Semantic)
|
||||||
|
if strings.EqualFold(source, dto.BillingUsageSourceOAIChat) ||
|
||||||
|
strings.EqualFold(source, dto.BillingUsageSourceOAIResponses) ||
|
||||||
|
strings.EqualFold(semantic, dto.BillingUsageSemanticOpenAI) {
|
||||||
|
if usage.BillingUsage.Estimated {
|
||||||
|
return usageBillingPathOpenAIEstimated
|
||||||
|
}
|
||||||
|
return usageBillingPathOpenAI
|
||||||
|
}
|
||||||
|
if strings.EqualFold(source, dto.BillingUsageSourceClaudeMessages) ||
|
||||||
|
strings.EqualFold(semantic, dto.BillingUsageSemanticAnthropic) {
|
||||||
|
if usage.BillingUsage.Estimated {
|
||||||
|
return usageBillingPathAnthropicEstimated
|
||||||
|
}
|
||||||
|
return usageBillingPathAnthropic
|
||||||
|
}
|
||||||
|
if strings.EqualFold(source, dto.BillingUsageSourceGeminiChat) ||
|
||||||
|
strings.EqualFold(semantic, dto.BillingUsageSemanticGemini) {
|
||||||
|
if usage.BillingUsage.Estimated {
|
||||||
|
return usageBillingPathGeminiEstimated
|
||||||
|
}
|
||||||
|
return usageBillingPathGemini
|
||||||
|
}
|
||||||
|
return usageBillingPathUpstream
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUsageBillingPathForLog(other map[string]interface{}, isLocalCountTokens bool, usage *dto.Usage) {
|
||||||
|
if other == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminInfo, ok := other["admin_info"].(map[string]interface{})
|
||||||
|
if !ok || adminInfo == nil {
|
||||||
|
adminInfo = make(map[string]interface{})
|
||||||
|
other["admin_info"] = adminInfo
|
||||||
|
}
|
||||||
|
adminInfo["usage_billing_path"] = usageBillingPathForLog(isLocalCountTokens, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) {
|
||||||
|
if usage == nil || usage.BillingUsage == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
billingUsage := usage.BillingUsage
|
||||||
|
source := strings.TrimSpace(billingUsage.Source)
|
||||||
|
semantic := strings.TrimSpace(billingUsage.Semantic)
|
||||||
|
|
||||||
|
if billingUsage.OpenAIUsage != nil &&
|
||||||
|
(strings.EqualFold(source, dto.BillingUsageSourceOAIChat) ||
|
||||||
|
strings.EqualFold(source, dto.BillingUsageSourceOAIResponses) ||
|
||||||
|
strings.EqualFold(semantic, dto.BillingUsageSemanticOpenAI)) {
|
||||||
|
return usageFromOpenAIBillingUsage(billingUsage), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if billingUsage.ClaudeUsage != nil &&
|
||||||
|
(strings.EqualFold(source, dto.BillingUsageSourceClaudeMessages) ||
|
||||||
|
strings.EqualFold(semantic, dto.BillingUsageSemanticAnthropic)) {
|
||||||
|
return usageFromClaudeBillingUsage(billingUsage), true
|
||||||
|
}
|
||||||
|
|
||||||
|
if billingUsage.GeminiUsageMetadata != nil &&
|
||||||
|
(strings.EqualFold(source, dto.BillingUsageSourceGeminiChat) ||
|
||||||
|
strings.EqualFold(semantic, dto.BillingUsageSemanticGemini)) {
|
||||||
|
return usageFromGeminiBillingUsage(billingUsage), true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromOpenAIBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||||
|
usage := *billingUsage.OpenAIUsage
|
||||||
|
if usage.PromptTokens == 0 && usage.InputTokens > 0 {
|
||||||
|
usage.PromptTokens = usage.InputTokens
|
||||||
|
}
|
||||||
|
if usage.CompletionTokens == 0 && usage.OutputTokens > 0 {
|
||||||
|
usage.CompletionTokens = usage.OutputTokens
|
||||||
|
}
|
||||||
|
if usage.InputTokens == 0 && usage.PromptTokens > 0 {
|
||||||
|
usage.InputTokens = usage.PromptTokens
|
||||||
|
}
|
||||||
|
if usage.OutputTokens == 0 && usage.CompletionTokens > 0 {
|
||||||
|
usage.OutputTokens = usage.CompletionTokens
|
||||||
|
}
|
||||||
|
if usage.TotalTokens == 0 {
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
}
|
||||||
|
usage.UsageSemantic = dto.BillingUsageSemanticOpenAI
|
||||||
|
usage.UsageSource = billingUsage.Source
|
||||||
|
usage.BillingUsage = dto.CloneBillingUsage(billingUsage)
|
||||||
|
return &usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromClaudeBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||||
|
claudeUsage := billingUsage.ClaudeUsage
|
||||||
|
cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
|
||||||
|
if cacheCreation5m == 0 {
|
||||||
|
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
|
||||||
|
}
|
||||||
|
cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
|
||||||
|
if cacheCreation1h == 0 {
|
||||||
|
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: claudeUsage.InputTokens,
|
||||||
|
CompletionTokens: claudeUsage.OutputTokens,
|
||||||
|
TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
|
||||||
|
InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
|
||||||
|
OutputTokens: claudeUsage.OutputTokens,
|
||||||
|
UsageSemantic: dto.BillingUsageSemanticAnthropic,
|
||||||
|
UsageSource: dto.BillingUsageSourceClaudeMessages,
|
||||||
|
BillingUsage: dto.CloneBillingUsage(billingUsage),
|
||||||
|
ClaudeCacheCreation5mTokens: cacheCreation5m,
|
||||||
|
ClaudeCacheCreation1hTokens: cacheCreation1h,
|
||||||
|
}
|
||||||
|
usage.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
|
||||||
|
usage.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageFromGeminiBillingUsage(billingUsage *dto.BillingUsage) *dto.Usage {
|
||||||
|
metadata := *billingUsage.GeminiUsageMetadata
|
||||||
|
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: promptTokens,
|
||||||
|
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
|
||||||
|
TotalTokens: metadata.TotalTokenCount,
|
||||||
|
UsageSemantic: dto.BillingUsageSemanticGemini,
|
||||||
|
UsageSource: dto.BillingUsageSourceGeminiChat,
|
||||||
|
BillingUsage: dto.CloneBillingUsage(billingUsage),
|
||||||
|
}
|
||||||
|
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
|
||||||
|
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
|
||||||
|
|
||||||
|
for _, detail := range metadata.PromptTokensDetails {
|
||||||
|
addGeminiInputTokenDetail(&usage.PromptTokensDetails, detail)
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.ToolUsePromptTokensDetails {
|
||||||
|
addGeminiInputTokenDetail(&usage.PromptTokensDetails, detail)
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.CandidatesTokensDetails {
|
||||||
|
switch detail.Modality {
|
||||||
|
case "IMAGE":
|
||||||
|
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
||||||
|
case "AUDIO":
|
||||||
|
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
|
||||||
|
case "TEXT":
|
||||||
|
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage.TotalTokens == 0 {
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
} else if usage.CompletionTokens <= 0 {
|
||||||
|
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||||
|
}
|
||||||
|
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
|
||||||
|
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func addGeminiInputTokenDetail(details *dto.InputTokenDetails, detail dto.GeminiPromptTokensDetails) {
|
||||||
|
switch detail.Modality {
|
||||||
|
case "AUDIO":
|
||||||
|
details.AudioTokens += detail.TokenCount
|
||||||
|
case "IMAGE":
|
||||||
|
details.ImageTokens += detail.TokenCount
|
||||||
|
case "TEXT":
|
||||||
|
details.TextTokens += detail.TokenCount
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-984
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponseConverterFacades(t *testing.T) {
|
||||||
|
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
|
||||||
|
assert.Equal(t, 8, cache5m)
|
||||||
|
assert.Equal(t, 2, cache1h)
|
||||||
|
|
||||||
|
chatResp := &dto.OpenAITextResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{
|
||||||
|
Message: dto.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "hello",
|
||||||
|
},
|
||||||
|
FinishReason: "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeResp := ResponseOpenAI2Claude(chatResp, &relaycommon.RelayInfo{})
|
||||||
|
require.NotNil(t, claudeResp)
|
||||||
|
assert.Equal(t, "message", claudeResp.Type)
|
||||||
|
|
||||||
|
geminiResp := ResponseOpenAI2Gemini(chatResp, &relaycommon.RelayInfo{})
|
||||||
|
require.NotNil(t, geminiResp)
|
||||||
|
require.Len(t, geminiResp.Candidates, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseConverterFacades(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
SendResponseCount: 1,
|
||||||
|
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||||
|
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
streamResp := &dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||||
|
Content: ptrValue("hello"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeResponses := StreamResponseOpenAI2Claude(streamResp, info)
|
||||||
|
require.NotEmpty(t, claudeResponses)
|
||||||
|
|
||||||
|
geminiResp := StreamResponseOpenAI2Gemini(streamResp, &relaycommon.RelayInfo{})
|
||||||
|
require.NotNil(t, geminiResp)
|
||||||
|
require.Len(t, geminiResp.Candidates, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrValue[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package claudemessages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
webSearchMaxUsesLow = 1
|
||||||
|
webSearchMaxUsesMedium = 5
|
||||||
|
webSearchMaxUsesHigh = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
type openRouterRequestReasoning struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Effort string `json:"effort,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Exclude bool `json:"exclude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
openAIRequest := dto.GeneralOpenAIRequest{
|
||||||
|
Model: claudeRequest.Model,
|
||||||
|
Temperature: claudeRequest.Temperature,
|
||||||
|
}
|
||||||
|
if claudeRequest.MaxTokens != nil {
|
||||||
|
openAIRequest.MaxTokens = common.GetPointer(*claudeRequest.MaxTokens)
|
||||||
|
}
|
||||||
|
if claudeRequest.TopP != nil {
|
||||||
|
openAIRequest.TopP = common.GetPointer(*claudeRequest.TopP)
|
||||||
|
}
|
||||||
|
if claudeRequest.TopK != nil {
|
||||||
|
openAIRequest.TopK = common.GetPointer(*claudeRequest.TopK)
|
||||||
|
}
|
||||||
|
if claudeRequest.Stream != nil {
|
||||||
|
openAIRequest.Stream = common.GetPointer(*claudeRequest.Stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpenRouter := relaymeta.RelayInfoChannelType(info) == constant.ChannelTypeOpenRouter
|
||||||
|
if isOpenRouter {
|
||||||
|
if effort := claudeRequest.GetEfforts(); effort != "" {
|
||||||
|
effortBytes, _ := common.Marshal(effort)
|
||||||
|
openAIRequest.Verbosity = effortBytes
|
||||||
|
}
|
||||||
|
if claudeRequest.Thinking != nil {
|
||||||
|
var reasoningConfig openRouterRequestReasoning
|
||||||
|
if claudeRequest.Thinking.Type == "enabled" {
|
||||||
|
reasoningConfig = openRouterRequestReasoning{
|
||||||
|
Enabled: true,
|
||||||
|
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
|
||||||
|
}
|
||||||
|
} else if claudeRequest.Thinking.Type == "adaptive" {
|
||||||
|
reasoningConfig = openRouterRequestReasoning{
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reasoningJSON, err := common.Marshal(reasoningConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
|
||||||
|
}
|
||||||
|
openAIRequest.Reasoning = reasoningJSON
|
||||||
|
}
|
||||||
|
} else if info != nil {
|
||||||
|
thinkingSuffix := "-thinking"
|
||||||
|
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
|
||||||
|
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
|
||||||
|
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(claudeRequest.StopSequences) == 1 {
|
||||||
|
openAIRequest.Stop = claudeRequest.StopSequences[0]
|
||||||
|
} else if len(claudeRequest.StopSequences) > 1 {
|
||||||
|
openAIRequest.Stop = claudeRequest.StopSequences
|
||||||
|
}
|
||||||
|
|
||||||
|
tools, _ := common.Any2Type[[]dto.Tool](claudeRequest.Tools)
|
||||||
|
openAITools := make([]dto.ToolCallRequest, 0)
|
||||||
|
for _, claudeTool := range tools {
|
||||||
|
openAITool := dto.ToolCallRequest{
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: claudeTool.Name,
|
||||||
|
Description: claudeTool.Description,
|
||||||
|
Parameters: claudeTool.InputSchema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
openAITools = append(openAITools, openAITool)
|
||||||
|
}
|
||||||
|
openAIRequest.Tools = openAITools
|
||||||
|
|
||||||
|
openAIMessages := make([]dto.Message, 0)
|
||||||
|
if claudeRequest.System != nil {
|
||||||
|
if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" {
|
||||||
|
openAIMessage := dto.Message{
|
||||||
|
Role: "system",
|
||||||
|
}
|
||||||
|
openAIMessage.SetStringContent(claudeRequest.GetStringSystem())
|
||||||
|
openAIMessages = append(openAIMessages, openAIMessage)
|
||||||
|
} else {
|
||||||
|
systems := claudeRequest.ParseSystem()
|
||||||
|
if len(systems) > 0 {
|
||||||
|
openAIMessage := dto.Message{
|
||||||
|
Role: "system",
|
||||||
|
}
|
||||||
|
isOpenRouterClaude := isOpenRouter && strings.HasPrefix(relaymeta.RelayInfoUpstreamModelName(info), "anthropic/claude")
|
||||||
|
if isOpenRouterClaude {
|
||||||
|
systemMediaMessages := make([]dto.MediaContent, 0, len(systems))
|
||||||
|
for _, system := range systems {
|
||||||
|
message := dto.MediaContent{
|
||||||
|
Type: "text",
|
||||||
|
Text: system.GetText(),
|
||||||
|
CacheControl: system.CacheControl,
|
||||||
|
}
|
||||||
|
systemMediaMessages = append(systemMediaMessages, message)
|
||||||
|
}
|
||||||
|
openAIMessage.SetMediaContent(systemMediaMessages)
|
||||||
|
} else {
|
||||||
|
systemStr := ""
|
||||||
|
for _, system := range systems {
|
||||||
|
if system.Text != nil {
|
||||||
|
systemStr += *system.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openAIMessage.SetStringContent(systemStr)
|
||||||
|
}
|
||||||
|
openAIMessages = append(openAIMessages, openAIMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, claudeMessage := range claudeRequest.Messages {
|
||||||
|
openAIMessage := dto.Message{
|
||||||
|
Role: claudeMessage.Role,
|
||||||
|
}
|
||||||
|
if claudeMessage.IsStringContent() {
|
||||||
|
openAIMessage.SetStringContent(claudeMessage.GetStringContent())
|
||||||
|
} else {
|
||||||
|
content, err := claudeMessage.ParseContent()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var toolCalls []dto.ToolCallRequest
|
||||||
|
mediaMessages := make([]dto.MediaContent, 0, len(content))
|
||||||
|
|
||||||
|
for _, mediaMsg := range content {
|
||||||
|
switch mediaMsg.Type {
|
||||||
|
case "text", "input_text":
|
||||||
|
message := dto.MediaContent{
|
||||||
|
Type: "text",
|
||||||
|
Text: mediaMsg.GetText(),
|
||||||
|
CacheControl: mediaMsg.CacheControl,
|
||||||
|
}
|
||||||
|
mediaMessages = append(mediaMessages, message)
|
||||||
|
case "image":
|
||||||
|
imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
|
||||||
|
mediaMessage := dto.MediaContent{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageUrl: &dto.MessageImageUrl{Url: imageData},
|
||||||
|
}
|
||||||
|
mediaMessages = append(mediaMessages, mediaMessage)
|
||||||
|
case "tool_use":
|
||||||
|
toolCall := dto.ToolCallRequest{
|
||||||
|
ID: mediaMsg.Id,
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: mediaMsg.Name,
|
||||||
|
Arguments: requestToJSONString(mediaMsg.Input),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
|
case "tool_result":
|
||||||
|
toolName := mediaMsg.Name
|
||||||
|
if toolName == "" {
|
||||||
|
toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
|
||||||
|
}
|
||||||
|
oaiToolMessage := dto.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Name: &toolName,
|
||||||
|
ToolCallId: mediaMsg.ToolUseId,
|
||||||
|
}
|
||||||
|
if mediaMsg.IsStringContent() {
|
||||||
|
oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
|
||||||
|
} else {
|
||||||
|
mediaContents := mediaMsg.ParseMediaContent()
|
||||||
|
encodedJSON, _ := common.Marshal(mediaContents)
|
||||||
|
oaiToolMessage.SetStringContent(string(encodedJSON))
|
||||||
|
}
|
||||||
|
openAIMessages = append(openAIMessages, oaiToolMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
openAIMessage.SetToolCalls(toolCalls)
|
||||||
|
}
|
||||||
|
if len(mediaMessages) > 0 && len(toolCalls) == 0 {
|
||||||
|
openAIMessage.SetMediaContent(mediaMessages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 {
|
||||||
|
openAIMessages = append(openAIMessages, openAIMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openAIRequest.Messages = openAIMessages
|
||||||
|
return &openAIRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestToJSONString(v interface{}) string {
|
||||||
|
b, err := common.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package claudemessages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/relay/reasonmap"
|
||||||
|
sharedclaude "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/claude"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/tidwall/sjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaudeResponseInfo struct {
|
||||||
|
ResponseId string
|
||||||
|
Created int64
|
||||||
|
Model string
|
||||||
|
ResponseText strings.Builder
|
||||||
|
Usage *dto.Usage
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func StopReasonClaudeToOpenAI(reason string) string {
|
||||||
|
return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
|
||||||
|
var response dto.ChatCompletionsStreamResponse
|
||||||
|
response.Object = "chat.completion.chunk"
|
||||||
|
response.Model = claudeResponse.Model
|
||||||
|
response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0)
|
||||||
|
tools := make([]dto.ToolCallResponse, 0)
|
||||||
|
fcIdx := 0
|
||||||
|
if claudeResponse.Index != nil {
|
||||||
|
fcIdx = *claudeResponse.Index
|
||||||
|
}
|
||||||
|
var choice dto.ChatCompletionsStreamResponseChoice
|
||||||
|
if claudeResponse.Type == "message_start" {
|
||||||
|
if claudeResponse.Message != nil {
|
||||||
|
response.Id = claudeResponse.Message.Id
|
||||||
|
response.Model = claudeResponse.Message.Model
|
||||||
|
}
|
||||||
|
choice.Delta.SetContentString("")
|
||||||
|
choice.Delta.Role = "assistant"
|
||||||
|
} else if claudeResponse.Type == "content_block_start" {
|
||||||
|
if claudeResponse.ContentBlock != nil {
|
||||||
|
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
|
||||||
|
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
|
||||||
|
}
|
||||||
|
if claudeResponse.ContentBlock.Type == "tool_use" {
|
||||||
|
tools = append(tools, dto.ToolCallResponse{
|
||||||
|
Index: common.GetPointer(fcIdx),
|
||||||
|
ID: claudeResponse.ContentBlock.Id,
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Name: claudeResponse.ContentBlock.Name,
|
||||||
|
Arguments: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else if claudeResponse.Type == "content_block_delta" {
|
||||||
|
if claudeResponse.Delta != nil {
|
||||||
|
choice.Delta.Content = claudeResponse.Delta.Text
|
||||||
|
switch claudeResponse.Delta.Type {
|
||||||
|
case "input_json_delta":
|
||||||
|
tools = append(tools, dto.ToolCallResponse{
|
||||||
|
Type: "function",
|
||||||
|
Index: common.GetPointer(fcIdx),
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Arguments: *claudeResponse.Delta.PartialJson,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "signature_delta":
|
||||||
|
signatureContent := "\n"
|
||||||
|
choice.Delta.ReasoningContent = &signatureContent
|
||||||
|
case "thinking_delta":
|
||||||
|
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if claudeResponse.Type == "message_delta" {
|
||||||
|
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
|
||||||
|
finishReason := StopReasonClaudeToOpenAI(*claudeResponse.Delta.StopReason)
|
||||||
|
if finishReason != "null" {
|
||||||
|
choice.FinishReason = &finishReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if claudeResponse.Type == "message_stop" {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(tools) > 0 {
|
||||||
|
choice.Delta.Content = nil
|
||||||
|
choice.Delta.ToolCalls = tools
|
||||||
|
}
|
||||||
|
response.Choices = append(response.Choices, choice)
|
||||||
|
|
||||||
|
return &response
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
|
||||||
|
choices := make([]dto.OpenAITextResponseChoice, 0)
|
||||||
|
fullTextResponse := dto.OpenAITextResponse{
|
||||||
|
Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()),
|
||||||
|
Object: "chat.completion",
|
||||||
|
Created: common.GetTimestamp(),
|
||||||
|
}
|
||||||
|
var responseText string
|
||||||
|
var responseThinking string
|
||||||
|
if len(claudeResponse.Content) > 0 {
|
||||||
|
responseText = claudeResponse.Content[0].GetText()
|
||||||
|
if claudeResponse.Content[0].Thinking != nil {
|
||||||
|
responseThinking = *claudeResponse.Content[0].Thinking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools := make([]dto.ToolCallResponse, 0)
|
||||||
|
thinkingContent := ""
|
||||||
|
|
||||||
|
fullTextResponse.Id = claudeResponse.Id
|
||||||
|
for _, message := range claudeResponse.Content {
|
||||||
|
switch message.Type {
|
||||||
|
case "tool_use":
|
||||||
|
args, _ := common.Marshal(message.Input)
|
||||||
|
tools = append(tools, dto.ToolCallResponse{
|
||||||
|
ID: message.Id,
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Name: message.Name,
|
||||||
|
Arguments: string(args),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "thinking":
|
||||||
|
if message.Thinking != nil {
|
||||||
|
thinkingContent = *message.Thinking
|
||||||
|
}
|
||||||
|
case "text":
|
||||||
|
responseText = message.GetText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
choice := dto.OpenAITextResponseChoice{
|
||||||
|
Index: 0,
|
||||||
|
Message: dto.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
},
|
||||||
|
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
|
||||||
|
}
|
||||||
|
choice.SetStringContent(responseText)
|
||||||
|
if len(responseThinking) > 0 {
|
||||||
|
choice.ReasoningContent = &responseThinking
|
||||||
|
}
|
||||||
|
if len(tools) > 0 {
|
||||||
|
choice.Message.SetToolCalls(tools)
|
||||||
|
}
|
||||||
|
if thinkingContent != "" {
|
||||||
|
choice.Message.ReasoningContent = &thinkingContent
|
||||||
|
}
|
||||||
|
fullTextResponse.Model = claudeResponse.Model
|
||||||
|
choices = append(choices, choice)
|
||||||
|
fullTextResponse.Choices = choices
|
||||||
|
return &fullTextResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
|
||||||
|
if usage == nil {
|
||||||
|
return &dto.Usage{}
|
||||||
|
}
|
||||||
|
semanticUsage := &dto.Usage{
|
||||||
|
PromptTokens: usage.InputTokens,
|
||||||
|
CompletionTokens: usage.OutputTokens,
|
||||||
|
UsageSemantic: "anthropic",
|
||||||
|
UsageSource: "anthropic",
|
||||||
|
BillingUsage: dto.CloneBillingUsage(usage.BillingUsage),
|
||||||
|
}
|
||||||
|
if semanticUsage.BillingUsage == nil {
|
||||||
|
semanticUsage.BillingUsage = dto.NewClaudeMessagesBillingUsage(usage)
|
||||||
|
}
|
||||||
|
semanticUsage.PromptTokensDetails.CachedTokens = usage.CacheReadInputTokens
|
||||||
|
semanticUsage.PromptTokensDetails.CachedCreationTokens = usage.CacheCreationInputTokens
|
||||||
|
semanticUsage.ClaudeCacheCreation5mTokens = usage.GetCacheCreation5mTokens()
|
||||||
|
semanticUsage.ClaudeCacheCreation1hTokens = usage.GetCacheCreation1hTokens()
|
||||||
|
return UsageFromClaudeUsage(semanticUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
|
||||||
|
mapped := buildOpenAIStyleUsageFromClaudeUsage(usage)
|
||||||
|
return &mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int {
|
||||||
|
if usage == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens
|
||||||
|
if splitCacheCreationTokens == 0 {
|
||||||
|
return usage.PromptTokensDetails.CachedCreationTokens
|
||||||
|
}
|
||||||
|
if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens {
|
||||||
|
return usage.PromptTokensDetails.CachedCreationTokens
|
||||||
|
}
|
||||||
|
return splitCacheCreationTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||||
|
if usage == nil {
|
||||||
|
return dto.Usage{}
|
||||||
|
}
|
||||||
|
clone := *usage
|
||||||
|
clone.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
|
||||||
|
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = sharedclaude.NormalizeCacheCreationSplit(
|
||||||
|
usage.PromptTokensDetails.CachedCreationTokens,
|
||||||
|
usage.ClaudeCacheCreation5mTokens,
|
||||||
|
usage.ClaudeCacheCreation1hTokens,
|
||||||
|
)
|
||||||
|
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||||
|
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||||
|
clone.PromptTokens = totalInputTokens
|
||||||
|
clone.InputTokens = totalInputTokens
|
||||||
|
clone.TotalTokens = totalInputTokens + usage.CompletionTokens
|
||||||
|
clone.UsageSemantic = "openai"
|
||||||
|
clone.UsageSource = "anthropic"
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
|
||||||
|
usage := &dto.ClaudeUsage{}
|
||||||
|
if claudeResponse != nil && claudeResponse.Usage != nil {
|
||||||
|
*usage = *claudeResponse.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
if claudeInfo == nil || claudeInfo.Usage == nil {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage.InputTokens == 0 && claudeInfo.Usage.PromptTokens > 0 {
|
||||||
|
usage.InputTokens = claudeInfo.Usage.PromptTokens
|
||||||
|
}
|
||||||
|
if usage.CacheReadInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedTokens > 0 {
|
||||||
|
usage.CacheReadInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedTokens
|
||||||
|
}
|
||||||
|
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
|
||||||
|
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
|
||||||
|
}
|
||||||
|
cacheCreation5m := 0
|
||||||
|
cacheCreation1h := 0
|
||||||
|
if usage.CacheCreation != nil {
|
||||||
|
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
|
||||||
|
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
|
||||||
|
} else {
|
||||||
|
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
|
||||||
|
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
|
||||||
|
}
|
||||||
|
cacheCreation5m, cacheCreation1h = sharedclaude.NormalizeCacheCreationSplit(
|
||||||
|
usage.CacheCreationInputTokens,
|
||||||
|
cacheCreation5m,
|
||||||
|
cacheCreation1h,
|
||||||
|
)
|
||||||
|
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
|
||||||
|
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
|
||||||
|
}
|
||||||
|
if usage.CacheCreation != nil {
|
||||||
|
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
|
||||||
|
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cacheCreation5m, cacheCreation1h := sharedclaude.NormalizeCacheCreationSplit(
|
||||||
|
usage.PromptTokensDetails.CachedCreationTokens,
|
||||||
|
usage.ClaudeCacheCreation5mTokens,
|
||||||
|
usage.ClaudeCacheCreation1hTokens,
|
||||||
|
)
|
||||||
|
claudeUsage := &dto.ClaudeUsage{
|
||||||
|
InputTokens: usage.PromptTokens,
|
||||||
|
CacheCreationInputTokens: usage.PromptTokensDetails.CachedCreationTokens,
|
||||||
|
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
|
||||||
|
OutputTokens: usage.CompletionTokens,
|
||||||
|
}
|
||||||
|
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||||
|
claudeUsage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||||
|
Ephemeral5mInputTokens: cacheCreation5m,
|
||||||
|
Ephemeral1hInputTokens: cacheCreation1h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
|
||||||
|
if data == "" || usage == nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
data = setMessageDeltaUsageInt(data, "usage.input_tokens", usage.InputTokens)
|
||||||
|
data = setMessageDeltaUsageInt(data, "usage.cache_read_input_tokens", usage.CacheReadInputTokens)
|
||||||
|
data = setMessageDeltaUsageInt(data, "usage.cache_creation_input_tokens", usage.CacheCreationInputTokens)
|
||||||
|
|
||||||
|
if usage.CacheCreation != nil {
|
||||||
|
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_5m_input_tokens", usage.CacheCreation.Ephemeral5mInputTokens)
|
||||||
|
data = setMessageDeltaUsageInt(data, "usage.cache_creation.ephemeral_1h_input_tokens", usage.CacheCreation.Ephemeral1hInputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMessageDeltaUsageInt(data string, path string, localValue int) string {
|
||||||
|
if localValue <= 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamValue := gjson.Get(data, path)
|
||||||
|
if upstreamValue.Exists() && upstreamValue.Int() > 0 {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
patchedData, err := sjson.Set(data, path, localValue)
|
||||||
|
if err != nil {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
return patchedData
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
||||||
|
if claudeInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if claudeInfo.Usage == nil {
|
||||||
|
claudeInfo.Usage = &dto.Usage{}
|
||||||
|
}
|
||||||
|
if claudeResponse.Type == "message_start" {
|
||||||
|
if claudeResponse.Message != nil {
|
||||||
|
claudeInfo.ResponseId = claudeResponse.Message.Id
|
||||||
|
claudeInfo.Model = claudeResponse.Message.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
|
||||||
|
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
|
||||||
|
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||||
|
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
|
||||||
|
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
|
||||||
|
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
|
||||||
|
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
|
||||||
|
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
|
||||||
|
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
|
||||||
|
}
|
||||||
|
} else if claudeResponse.Type == "content_block_delta" {
|
||||||
|
if claudeResponse.Delta != nil {
|
||||||
|
if claudeResponse.Delta.Text != nil {
|
||||||
|
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
|
||||||
|
}
|
||||||
|
if claudeResponse.Delta.Thinking != nil {
|
||||||
|
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if claudeResponse.Type == "message_delta" {
|
||||||
|
if claudeResponse.Usage != nil {
|
||||||
|
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||||
|
if claudeResponse.Usage.InputTokens > 0 {
|
||||||
|
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
||||||
|
}
|
||||||
|
if claudeResponse.Usage.CacheReadInputTokens > 0 {
|
||||||
|
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||||
|
}
|
||||||
|
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
|
||||||
|
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||||
|
}
|
||||||
|
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
|
||||||
|
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
|
||||||
|
}
|
||||||
|
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
|
||||||
|
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
|
||||||
|
}
|
||||||
|
if claudeResponse.Usage.OutputTokens > 0 {
|
||||||
|
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||||
|
}
|
||||||
|
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||||
|
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeInfo.Done = true
|
||||||
|
} else if claudeResponse.Type == "content_block_start" {
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if oaiResponse != nil {
|
||||||
|
oaiResponse.Id = claudeInfo.ResponseId
|
||||||
|
oaiResponse.Created = claudeInfo.Created
|
||||||
|
oaiResponse.Model = claudeInfo.Model
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package geminichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert/internal/jsonutil"
|
||||||
|
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
modelName := ""
|
||||||
|
isStream := false
|
||||||
|
if info != nil {
|
||||||
|
isStream = info.IsStream
|
||||||
|
}
|
||||||
|
modelName = relaymeta.RelayInfoUpstreamModelName(info)
|
||||||
|
openaiRequest := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: modelName,
|
||||||
|
Stream: common.GetPointer(isStream),
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []dto.Message
|
||||||
|
for _, content := range geminiRequest.Contents {
|
||||||
|
message := dto.Message{
|
||||||
|
Role: convertGeminiRoleToOpenAI(content.Role),
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaContents []dto.MediaContent
|
||||||
|
var toolCalls []dto.ToolCallRequest
|
||||||
|
for _, part := range content.Parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
mediaContent := dto.MediaContent{
|
||||||
|
Type: "text",
|
||||||
|
Text: part.Text,
|
||||||
|
}
|
||||||
|
mediaContents = append(mediaContents, mediaContent)
|
||||||
|
} else if part.InlineData != nil {
|
||||||
|
mediaContent := dto.MediaContent{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageUrl: &dto.MessageImageUrl{
|
||||||
|
Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
|
||||||
|
Detail: "auto",
|
||||||
|
MimeType: part.InlineData.MimeType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mediaContents = append(mediaContents, mediaContent)
|
||||||
|
} else if part.FileData != nil {
|
||||||
|
mediaContent := dto.MediaContent{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageUrl: &dto.MessageImageUrl{
|
||||||
|
Url: part.FileData.FileUri,
|
||||||
|
Detail: "auto",
|
||||||
|
MimeType: part.FileData.MimeType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mediaContents = append(mediaContents, mediaContent)
|
||||||
|
} else if part.FunctionCall != nil {
|
||||||
|
toolCall := dto.ToolCallRequest{
|
||||||
|
ID: fmt.Sprintf("call_%d", len(toolCalls)+1),
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: part.FunctionCall.FunctionName,
|
||||||
|
Arguments: jsonutil.ToJSONString(part.FunctionCall.Arguments),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, toolCall)
|
||||||
|
} else if part.FunctionResponse != nil {
|
||||||
|
toolMessage := dto.Message{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)),
|
||||||
|
}
|
||||||
|
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
|
||||||
|
messages = append(messages, toolMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
message.SetToolCalls(toolCalls)
|
||||||
|
} else if len(mediaContents) == 1 && mediaContents[0].Type == "text" {
|
||||||
|
message.Content = mediaContents[0].Text
|
||||||
|
} else if len(mediaContents) > 0 {
|
||||||
|
message.SetMediaContent(mediaContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
|
||||||
|
messages = append(messages, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiRequest.Messages = messages
|
||||||
|
|
||||||
|
if geminiRequest.GenerationConfig.Temperature != nil {
|
||||||
|
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
|
||||||
|
}
|
||||||
|
if geminiRequest.GenerationConfig.TopP != nil && *geminiRequest.GenerationConfig.TopP > 0 {
|
||||||
|
openaiRequest.TopP = common.GetPointer(*geminiRequest.GenerationConfig.TopP)
|
||||||
|
}
|
||||||
|
if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
|
||||||
|
openaiRequest.TopK = common.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
|
||||||
|
}
|
||||||
|
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
|
||||||
|
openaiRequest.MaxTokens = common.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||||
|
}
|
||||||
|
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
|
||||||
|
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
|
||||||
|
}
|
||||||
|
if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
|
||||||
|
openaiRequest.N = common.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(geminiRequest.GetTools()) > 0 {
|
||||||
|
var tools []dto.ToolCallRequest
|
||||||
|
for _, tool := range geminiRequest.GetTools() {
|
||||||
|
if tool.FunctionDeclarations == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
functionDeclarations, err := common.Any2Type[[]dto.FunctionRequest](tool.FunctionDeclarations)
|
||||||
|
if err != nil {
|
||||||
|
common.SysError(fmt.Sprintf("failed to parse gemini function declarations: %v (type=%T)", err, tool.FunctionDeclarations))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, function := range functionDeclarations {
|
||||||
|
openAITool := dto.ToolCallRequest{
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: function.Name,
|
||||||
|
Description: function.Description,
|
||||||
|
Parameters: function.Parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tools = append(tools, openAITool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tools) > 0 {
|
||||||
|
openaiRequest.Tools = tools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if geminiRequest.SystemInstructions != nil {
|
||||||
|
systemMessage := dto.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts),
|
||||||
|
}
|
||||||
|
openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertGeminiRoleToOpenAI(geminiRole string) string {
|
||||||
|
switch geminiRole {
|
||||||
|
case "user":
|
||||||
|
return "user"
|
||||||
|
case "model":
|
||||||
|
return "assistant"
|
||||||
|
case "function":
|
||||||
|
return "function"
|
||||||
|
default:
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTextFromGeminiParts(parts []dto.GeminiPart) string {
|
||||||
|
texts := make([]string, 0)
|
||||||
|
for _, part := range parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
texts = append(texts, part.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(texts, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package geminichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
|
||||||
|
if metadata == nil {
|
||||||
|
if fallbackPromptTokens <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
|
||||||
|
usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
|
||||||
|
if promptTokens <= 0 && fallbackPromptTokens > 0 {
|
||||||
|
promptTokens = fallbackPromptTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: promptTokens,
|
||||||
|
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
|
||||||
|
TotalTokens: metadata.TotalTokenCount,
|
||||||
|
BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
|
||||||
|
}
|
||||||
|
if usage.BillingUsage == nil {
|
||||||
|
usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
|
||||||
|
}
|
||||||
|
usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
|
||||||
|
usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
|
||||||
|
|
||||||
|
for _, detail := range metadata.PromptTokensDetails {
|
||||||
|
if detail.Modality == "AUDIO" {
|
||||||
|
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
|
||||||
|
} else if detail.Modality == "IMAGE" {
|
||||||
|
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
|
||||||
|
} else if detail.Modality == "TEXT" {
|
||||||
|
usage.PromptTokensDetails.TextTokens += detail.TokenCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.ToolUsePromptTokensDetails {
|
||||||
|
if detail.Modality == "AUDIO" {
|
||||||
|
usage.PromptTokensDetails.AudioTokens += detail.TokenCount
|
||||||
|
} else if detail.Modality == "IMAGE" {
|
||||||
|
usage.PromptTokensDetails.ImageTokens += detail.TokenCount
|
||||||
|
} else if detail.Modality == "TEXT" {
|
||||||
|
usage.PromptTokensDetails.TextTokens += detail.TokenCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, detail := range metadata.CandidatesTokensDetails {
|
||||||
|
switch detail.Modality {
|
||||||
|
case "IMAGE":
|
||||||
|
usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
||||||
|
case "AUDIO":
|
||||||
|
usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
|
||||||
|
case "TEXT":
|
||||||
|
usage.CompletionTokenDetails.TextTokens += detail.TokenCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
|
||||||
|
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
|
||||||
|
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
|
||||||
|
fullTextResponse := dto.OpenAITextResponse{
|
||||||
|
Id: id,
|
||||||
|
Object: "chat.completion",
|
||||||
|
Created: created,
|
||||||
|
Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
|
||||||
|
}
|
||||||
|
isToolCall := false
|
||||||
|
for _, candidate := range response.Candidates {
|
||||||
|
choice := dto.OpenAITextResponseChoice{
|
||||||
|
Index: int(candidate.Index),
|
||||||
|
Message: dto.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
},
|
||||||
|
FinishReason: constant.FinishReasonStop,
|
||||||
|
}
|
||||||
|
if len(candidate.Content.Parts) > 0 {
|
||||||
|
var content strings.Builder
|
||||||
|
var inlineGrow int
|
||||||
|
for _, part := range candidate.Content.Parts {
|
||||||
|
if part.InlineData != nil {
|
||||||
|
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inlineGrow > 0 {
|
||||||
|
content.Grow(inlineGrow)
|
||||||
|
}
|
||||||
|
appended := 0
|
||||||
|
writeSep := func() {
|
||||||
|
if appended > 0 {
|
||||||
|
content.WriteByte('\n')
|
||||||
|
}
|
||||||
|
appended++
|
||||||
|
}
|
||||||
|
var toolCalls []dto.ToolCallResponse
|
||||||
|
for _, part := range candidate.Content.Parts {
|
||||||
|
if part.InlineData != nil {
|
||||||
|
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("
|
||||||
|
content.WriteString(part.InlineData.MimeType)
|
||||||
|
content.WriteString(";base64,")
|
||||||
|
content.WriteString(part.InlineData.Data)
|
||||||
|
content.WriteByte(')')
|
||||||
|
} else {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("[media](data:")
|
||||||
|
content.WriteString(part.InlineData.MimeType)
|
||||||
|
content.WriteString(";base64,")
|
||||||
|
content.WriteString(part.InlineData.Data)
|
||||||
|
content.WriteByte(')')
|
||||||
|
}
|
||||||
|
} else if part.FunctionCall != nil {
|
||||||
|
choice.FinishReason = constant.FinishReasonToolCalls
|
||||||
|
if call := geminiResponseToolCall(&part); call != nil {
|
||||||
|
toolCalls = append(toolCalls, *call)
|
||||||
|
}
|
||||||
|
} else if part.Thought {
|
||||||
|
choice.Message.ReasoningContent = &part.Text
|
||||||
|
} else {
|
||||||
|
if part.ExecutableCode != nil {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("```")
|
||||||
|
content.WriteString(part.ExecutableCode.Language)
|
||||||
|
content.WriteByte('\n')
|
||||||
|
content.WriteString(part.ExecutableCode.Code)
|
||||||
|
content.WriteString("\n```")
|
||||||
|
} else if part.CodeExecutionResult != nil {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("```output\n")
|
||||||
|
content.WriteString(part.CodeExecutionResult.Output)
|
||||||
|
content.WriteString("\n```")
|
||||||
|
} else if part.Text != "\n" {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString(part.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
choice.Message.SetToolCalls(toolCalls)
|
||||||
|
isToolCall = true
|
||||||
|
}
|
||||||
|
choice.Message.SetStringContent(content.String())
|
||||||
|
}
|
||||||
|
if candidate.FinishReason != nil {
|
||||||
|
switch *candidate.FinishReason {
|
||||||
|
case "STOP":
|
||||||
|
choice.FinishReason = constant.FinishReasonStop
|
||||||
|
case "MAX_TOKENS":
|
||||||
|
choice.FinishReason = constant.FinishReasonLength
|
||||||
|
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
|
||||||
|
choice.FinishReason = constant.FinishReasonContentFilter
|
||||||
|
default:
|
||||||
|
choice.FinishReason = constant.FinishReasonContentFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isToolCall {
|
||||||
|
choice.FinishReason = constant.FinishReasonToolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
|
||||||
|
}
|
||||||
|
return &fullTextResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
|
||||||
|
choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
|
||||||
|
isStop := false
|
||||||
|
for _, candidate := range geminiResponse.Candidates {
|
||||||
|
if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
|
||||||
|
isStop = true
|
||||||
|
candidate.FinishReason = nil
|
||||||
|
}
|
||||||
|
choice := dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
Index: int(candidate.Index),
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
|
||||||
|
}
|
||||||
|
var content strings.Builder
|
||||||
|
var inlineGrow int
|
||||||
|
for _, part := range candidate.Content.Parts {
|
||||||
|
if part.InlineData != nil {
|
||||||
|
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inlineGrow > 0 {
|
||||||
|
content.Grow(inlineGrow)
|
||||||
|
}
|
||||||
|
appended := 0
|
||||||
|
writeSep := func() {
|
||||||
|
if appended > 0 {
|
||||||
|
content.WriteByte('\n')
|
||||||
|
}
|
||||||
|
appended++
|
||||||
|
}
|
||||||
|
isTools := false
|
||||||
|
isThought := false
|
||||||
|
if candidate.FinishReason != nil {
|
||||||
|
switch *candidate.FinishReason {
|
||||||
|
case "STOP":
|
||||||
|
choice.FinishReason = &constant.FinishReasonStop
|
||||||
|
case "MAX_TOKENS":
|
||||||
|
choice.FinishReason = &constant.FinishReasonLength
|
||||||
|
case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
|
||||||
|
choice.FinishReason = &constant.FinishReasonContentFilter
|
||||||
|
default:
|
||||||
|
choice.FinishReason = &constant.FinishReasonContentFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, part := range candidate.Content.Parts {
|
||||||
|
if part.InlineData != nil {
|
||||||
|
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("
|
||||||
|
content.WriteString(part.InlineData.MimeType)
|
||||||
|
content.WriteString(";base64,")
|
||||||
|
content.WriteString(part.InlineData.Data)
|
||||||
|
content.WriteByte(')')
|
||||||
|
}
|
||||||
|
} else if part.FunctionCall != nil {
|
||||||
|
isTools = true
|
||||||
|
if call := geminiResponseToolCall(&part); call != nil {
|
||||||
|
call.SetIndex(len(choice.Delta.ToolCalls))
|
||||||
|
choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
|
||||||
|
}
|
||||||
|
} else if part.Thought {
|
||||||
|
isThought = true
|
||||||
|
writeSep()
|
||||||
|
content.WriteString(part.Text)
|
||||||
|
} else {
|
||||||
|
if part.ExecutableCode != nil {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("```")
|
||||||
|
content.WriteString(part.ExecutableCode.Language)
|
||||||
|
content.WriteByte('\n')
|
||||||
|
content.WriteString(part.ExecutableCode.Code)
|
||||||
|
content.WriteString("\n```\n")
|
||||||
|
} else if part.CodeExecutionResult != nil {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString("```output\n")
|
||||||
|
content.WriteString(part.CodeExecutionResult.Output)
|
||||||
|
content.WriteString("\n```\n")
|
||||||
|
} else if part.Text != "\n" {
|
||||||
|
writeSep()
|
||||||
|
content.WriteString(part.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isThought {
|
||||||
|
choice.Delta.SetReasoningContent(content.String())
|
||||||
|
} else {
|
||||||
|
choice.Delta.SetContentString(content.String())
|
||||||
|
}
|
||||||
|
if isTools {
|
||||||
|
choice.FinishReason = &constant.FinishReasonToolCalls
|
||||||
|
}
|
||||||
|
choices = append(choices, choice)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.ChatCompletionsStreamResponse{
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Choices: choices,
|
||||||
|
}
|
||||||
|
return &response, isStop
|
||||||
|
}
|
||||||
|
|
||||||
|
func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
|
||||||
|
argsBytes, err := common.Marshal(item.FunctionCall.Arguments)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &dto.ToolCallResponse{
|
||||||
|
ID: fmt.Sprintf("call_%s", common.GetUUID()),
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Arguments: string(argsBytes),
|
||||||
|
Name: item.FunctionCall.FunctionName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package jsonutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ToJSONString(v interface{}) string {
|
||||||
|
bytes, err := common.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return string(bytes)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package relayconvert
|
package matcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
var compiledRegexCache sync.Map // map[string]*regexp.Regexp
|
var compiledRegexCache sync.Map // map[string]*regexp.Regexp
|
||||||
|
|
||||||
func matchAnyRegex(patterns []string, s string) bool {
|
func MatchAnyRegex(patterns []string, s string) bool {
|
||||||
if len(patterns) == 0 || s == "" {
|
if len(patterns) == 0 || s == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaResolver struct {
|
||||||
|
GetBase64Data func(c *gin.Context, source types.FileSource, reason ...string) (string, string, error)
|
||||||
|
DecodeBase64FileData func(base64String string) (string, string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mediaResolverMu sync.RWMutex
|
||||||
|
mediaResolver MediaResolver
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetMediaResolver(resolver MediaResolver) {
|
||||||
|
mediaResolverMu.Lock()
|
||||||
|
defer mediaResolverMu.Unlock()
|
||||||
|
|
||||||
|
mediaResolver = resolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
|
||||||
|
mediaResolverMu.RLock()
|
||||||
|
resolver := mediaResolver.GetBase64Data
|
||||||
|
mediaResolverMu.RUnlock()
|
||||||
|
if resolver == nil {
|
||||||
|
return "", "", errors.New("relayconvert media resolver is not configured")
|
||||||
|
}
|
||||||
|
return resolver(c, source, reason...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeBase64FileData(base64String string) (string, string, error) {
|
||||||
|
mediaResolverMu.RLock()
|
||||||
|
resolver := mediaResolver.DecodeBase64FileData
|
||||||
|
mediaResolverMu.RUnlock()
|
||||||
|
if resolver == nil {
|
||||||
|
return "", "", errors.New("relayconvert media resolver is not configured")
|
||||||
|
}
|
||||||
|
return resolver(base64String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package meta
|
||||||
|
|
||||||
|
import relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
|
||||||
|
func RelayInfoChannelType(info *relaycommon.RelayInfo) int {
|
||||||
|
if info == nil || info.ChannelMeta == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return info.ChannelType
|
||||||
|
}
|
||||||
|
|
||||||
|
func RelayInfoUpstreamModelName(info *relaycommon.RelayInfo) string {
|
||||||
|
if info == nil || info.ChannelMeta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return info.UpstreamModelName
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||||
|
sharedclaude "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/claude"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
webSearchMaxUsesLow = 1
|
||||||
|
webSearchMaxUsesMedium = 5
|
||||||
|
webSearchMaxUsesHigh = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
type openRouterRequestReasoning struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Effort string `json:"effort,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Exclude bool `json:"exclude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIChatRequestToClaudeMessages(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||||
|
claudeTools := make([]any, 0, len(textRequest.Tools))
|
||||||
|
|
||||||
|
for _, tool := range textRequest.Tools {
|
||||||
|
if params, ok := tool.Function.Parameters.(map[string]any); ok {
|
||||||
|
claudeTool := dto.Tool{
|
||||||
|
Name: tool.Function.Name,
|
||||||
|
Description: tool.Function.Description,
|
||||||
|
}
|
||||||
|
claudeTool.InputSchema = make(map[string]interface{})
|
||||||
|
if params["type"] != nil {
|
||||||
|
claudeTool.InputSchema["type"] = params["type"].(string)
|
||||||
|
}
|
||||||
|
claudeTool.InputSchema["properties"] = params["properties"]
|
||||||
|
claudeTool.InputSchema["required"] = params["required"]
|
||||||
|
for key, value := range params {
|
||||||
|
if key == "type" || key == "properties" || key == "required" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
claudeTool.InputSchema[key] = value
|
||||||
|
}
|
||||||
|
claudeTools = append(claudeTools, &claudeTool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.WebSearchOptions != nil {
|
||||||
|
webSearchTool := dto.ClaudeWebSearchTool{
|
||||||
|
Type: "web_search_20250305",
|
||||||
|
Name: "web_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.WebSearchOptions.UserLocation != nil {
|
||||||
|
anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{
|
||||||
|
Type: "approximate",
|
||||||
|
}
|
||||||
|
|
||||||
|
var userLocationMap map[string]interface{}
|
||||||
|
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
||||||
|
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
|
||||||
|
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
|
||||||
|
anthropicUserLocation.Timezone = timezone
|
||||||
|
}
|
||||||
|
if country, ok := approximateData["country"].(string); ok && country != "" {
|
||||||
|
anthropicUserLocation.Country = country
|
||||||
|
}
|
||||||
|
if region, ok := approximateData["region"].(string); ok && region != "" {
|
||||||
|
anthropicUserLocation.Region = region
|
||||||
|
}
|
||||||
|
if city, ok := approximateData["city"].(string); ok && city != "" {
|
||||||
|
anthropicUserLocation.City = city
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webSearchTool.UserLocation = anthropicUserLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
switch textRequest.WebSearchOptions.SearchContextSize {
|
||||||
|
case "low":
|
||||||
|
webSearchTool.MaxUses = webSearchMaxUsesLow
|
||||||
|
case "medium":
|
||||||
|
webSearchTool.MaxUses = webSearchMaxUsesMedium
|
||||||
|
case "high":
|
||||||
|
webSearchTool.MaxUses = webSearchMaxUsesHigh
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeTools = append(claudeTools, &webSearchTool)
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeRequest := dto.ClaudeRequest{
|
||||||
|
Model: textRequest.Model,
|
||||||
|
StopSequences: nil,
|
||||||
|
Temperature: textRequest.Temperature,
|
||||||
|
Tools: claudeTools,
|
||||||
|
}
|
||||||
|
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
|
||||||
|
claudeRequest.MaxTokens = common.GetPointer(maxTokens)
|
||||||
|
}
|
||||||
|
if textRequest.TopP != nil {
|
||||||
|
claudeRequest.TopP = common.GetPointer(*textRequest.TopP)
|
||||||
|
}
|
||||||
|
if textRequest.TopK != nil {
|
||||||
|
claudeRequest.TopK = common.GetPointer(*textRequest.TopK)
|
||||||
|
}
|
||||||
|
if textRequest.IsStream(nil) {
|
||||||
|
claudeRequest.Stream = common.GetPointer(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil {
|
||||||
|
claudeToolChoice := sharedclaude.MapOpenAIToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls)
|
||||||
|
if claudeToolChoice != nil {
|
||||||
|
claudeRequest.ToolChoice = claudeToolChoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
|
||||||
|
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
|
||||||
|
claudeRequest.MaxTokens = &defaultMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
||||||
|
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
|
||||||
|
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
|
||||||
|
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
|
||||||
|
claudeRequest.Model = baseModel
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "adaptive",
|
||||||
|
}
|
||||||
|
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||||
|
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||||
|
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||||
|
claudeRequest.Thinking.Display = "summarized"
|
||||||
|
claudeRequest.Temperature = nil
|
||||||
|
claudeRequest.TopP = nil
|
||||||
|
claudeRequest.TopK = nil
|
||||||
|
} else {
|
||||||
|
claudeRequest.TopP = nil
|
||||||
|
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||||
|
}
|
||||||
|
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
||||||
|
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||||
|
|
||||||
|
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||||
|
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
||||||
|
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||||
|
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||||
|
claudeRequest.Temperature = nil
|
||||||
|
claudeRequest.TopP = nil
|
||||||
|
claudeRequest.TopK = nil
|
||||||
|
} else {
|
||||||
|
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
|
||||||
|
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
||||||
|
}
|
||||||
|
claudeRequest.TopP = nil
|
||||||
|
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||||
|
}
|
||||||
|
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||||
|
claudeRequest.Model = trimmedModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.ReasoningEffort != "" {
|
||||||
|
switch textRequest.ReasoningEffort {
|
||||||
|
case "low":
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer[int](1280),
|
||||||
|
}
|
||||||
|
case "medium":
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer[int](2048),
|
||||||
|
}
|
||||||
|
case "high":
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer[int](4096),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.Reasoning != nil {
|
||||||
|
var reasoningConfig openRouterRequestReasoning
|
||||||
|
if err := common.Unmarshal(textRequest.Reasoning, &reasoningConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetTokens := reasoningConfig.MaxTokens
|
||||||
|
if budgetTokens > 0 {
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: &budgetTokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.Stop != nil {
|
||||||
|
switch stop := textRequest.Stop.(type) {
|
||||||
|
case string:
|
||||||
|
claudeRequest.StopSequences = []string{stop}
|
||||||
|
case []interface{}:
|
||||||
|
stopSequences := make([]string, 0)
|
||||||
|
for _, item := range stop {
|
||||||
|
stopSequences = append(stopSequences, item.(string))
|
||||||
|
}
|
||||||
|
claudeRequest.StopSequences = stopSequences
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatMessages := make([]dto.Message, 0)
|
||||||
|
lastMessage := dto.Message{
|
||||||
|
Role: "tool",
|
||||||
|
}
|
||||||
|
for i, message := range textRequest.Messages {
|
||||||
|
if message.Role == "" {
|
||||||
|
textRequest.Messages[i].Role = "user"
|
||||||
|
}
|
||||||
|
fmtMessage := dto.Message{
|
||||||
|
Role: message.Role,
|
||||||
|
Content: message.Content,
|
||||||
|
}
|
||||||
|
if message.Role == "tool" {
|
||||||
|
fmtMessage.ToolCallId = message.ToolCallId
|
||||||
|
}
|
||||||
|
if message.Role == "assistant" && message.ToolCalls != nil {
|
||||||
|
fmtMessage.ToolCalls = message.ToolCalls
|
||||||
|
}
|
||||||
|
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
|
||||||
|
if lastMessage.IsStringContent() && message.IsStringContent() {
|
||||||
|
fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
|
||||||
|
formatMessages = formatMessages[:len(formatMessages)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
|
||||||
|
fmtMessage.SetStringContent("...")
|
||||||
|
}
|
||||||
|
formatMessages = append(formatMessages, fmtMessage)
|
||||||
|
lastMessage = fmtMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessages := make([]dto.ClaudeMessage, 0)
|
||||||
|
isFirstMessage := true
|
||||||
|
var systemMessages []dto.ClaudeMediaMessage
|
||||||
|
|
||||||
|
for _, message := range formatMessages {
|
||||||
|
if message.Role == "system" {
|
||||||
|
if message.IsStringContent() {
|
||||||
|
if text := message.StringContent(); text != "" {
|
||||||
|
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, ctx := range message.ParseContent() {
|
||||||
|
if ctx.Type == "text" && ctx.Text != "" {
|
||||||
|
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](ctx.Text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isFirstMessage {
|
||||||
|
isFirstMessage = false
|
||||||
|
if message.Role != "user" {
|
||||||
|
claudeMessage := dto.ClaudeMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: []dto.ClaudeMediaMessage{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string]("..."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
claudeMessages = append(claudeMessages, claudeMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessage := dto.ClaudeMessage{
|
||||||
|
Role: message.Role,
|
||||||
|
}
|
||||||
|
if message.Role == "tool" {
|
||||||
|
if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" {
|
||||||
|
lastClaudeMessage := claudeMessages[len(claudeMessages)-1]
|
||||||
|
if content, ok := lastClaudeMessage.Content.(string); ok {
|
||||||
|
lastClaudeMessage.Content = []dto.ClaudeMediaMessage{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](content),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastClaudeMessage.Content = append(lastClaudeMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{
|
||||||
|
Type: "tool_result",
|
||||||
|
ToolUseId: message.ToolCallId,
|
||||||
|
Content: message.Content,
|
||||||
|
})
|
||||||
|
claudeMessages[len(claudeMessages)-1] = lastClaudeMessage
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMessage.Role = "user"
|
||||||
|
claudeMessage.Content = []dto.ClaudeMediaMessage{
|
||||||
|
{
|
||||||
|
Type: "tool_result",
|
||||||
|
ToolUseId: message.ToolCallId,
|
||||||
|
Content: message.Content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if message.IsStringContent() && message.ToolCalls == nil {
|
||||||
|
text := message.StringContent()
|
||||||
|
if text == "" {
|
||||||
|
text = "..."
|
||||||
|
}
|
||||||
|
claudeMessage.Content = text
|
||||||
|
} else {
|
||||||
|
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
|
||||||
|
for _, mediaMessage := range message.ParseContent() {
|
||||||
|
switch mediaMessage.Type {
|
||||||
|
case "text":
|
||||||
|
if mediaMessage.Text != "" {
|
||||||
|
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](mediaMessage.Text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
source := mediaMessage.ToFileSource()
|
||||||
|
if source == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Claude")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||||
|
Source: &dto.ClaudeMessageSource{
|
||||||
|
Type: "base64",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||||
|
claudeMediaMessage.Type = "document"
|
||||||
|
} else {
|
||||||
|
claudeMediaMessage.Type = "image"
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeMediaMessage.Source.MediaType = mimeType
|
||||||
|
claudeMediaMessage.Source.Data = base64Data
|
||||||
|
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.ToolCalls != nil {
|
||||||
|
for _, toolCall := range message.ParseToolCalls() {
|
||||||
|
inputObj := make(map[string]any)
|
||||||
|
if args := toolCall.Function.Arguments; args != "" {
|
||||||
|
if err := common.Unmarshal([]byte(args), &inputObj); err != nil {
|
||||||
|
common.SysLog("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||||
|
Type: "tool_use",
|
||||||
|
Id: toolCall.ID,
|
||||||
|
Name: toolCall.Function.Name,
|
||||||
|
Input: inputObj,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claudeMessage.Content = claudeMediaMessages
|
||||||
|
}
|
||||||
|
claudeMessages = append(claudeMessages, claudeMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(systemMessages) > 0 {
|
||||||
|
claudeRequest.System = systemMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeRequest.Prompt = ""
|
||||||
|
claudeRequest.Messages = claudeMessages
|
||||||
|
return &claudeRequest, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/relay/reasonmap"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateStopBlock(index int) *dto.ClaudeResponse {
|
||||||
|
return &dto.ClaudeResponse{
|
||||||
|
Type: "content_block_stop",
|
||||||
|
Index: common.GetPointer[int](index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||||
|
if oaiUsage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if billingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); billingUsage != nil && billingUsage.ClaudeUsage != nil {
|
||||||
|
if billingUsage.Source == dto.BillingUsageSourceClaudeMessages || billingUsage.Semantic == dto.BillingUsageSemanticAnthropic {
|
||||||
|
return billingUsage.ClaudeUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
billingUsage := dto.NewOpenAIChatBillingUsage(oaiUsage)
|
||||||
|
if existingBillingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
|
||||||
|
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
|
||||||
|
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
|
||||||
|
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
|
||||||
|
billingUsage = existingBillingUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
|
||||||
|
oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||||
|
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||||
|
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||||
|
)
|
||||||
|
usage := &dto.ClaudeUsage{
|
||||||
|
InputTokens: oaiUsage.PromptTokens,
|
||||||
|
OutputTokens: oaiUsage.CompletionTokens,
|
||||||
|
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||||
|
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||||
|
BillingUsage: billingUsage,
|
||||||
|
}
|
||||||
|
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||||
|
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||||
|
Ephemeral5mInputTokens: cacheCreation5m,
|
||||||
|
Ephemeral1hInputTokens: cacheCreation1h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||||
|
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
|
||||||
|
return tokens5m + remainder, tokens1h
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||||
|
if info == nil {
|
||||||
|
info = &relaycommon.RelayInfo{}
|
||||||
|
}
|
||||||
|
if info.ClaudeConvertInfo == nil {
|
||||||
|
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{
|
||||||
|
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info.ClaudeConvertInfo.Done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var claudeResponses []*dto.ClaudeResponse
|
||||||
|
// stopOpenBlocks emits the required content_block_stop event(s) for the currently open block(s)
|
||||||
|
// according to Anthropic's SSE streaming state machine:
|
||||||
|
// content_block_start -> content_block_delta* -> content_block_stop (per index).
|
||||||
|
//
|
||||||
|
// For text/thinking, there is at most one open block at info.ClaudeConvertInfo.Index.
|
||||||
|
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
|
||||||
|
// so we may have multiple open blocks and must stop each one explicitly.
|
||||||
|
stopOpenBlocks := func() {
|
||||||
|
switch info.ClaudeConvertInfo.LastMessagesType {
|
||||||
|
case relaycommon.LastMessageTypeText, relaycommon.LastMessageTypeThinking:
|
||||||
|
claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index))
|
||||||
|
case relaycommon.LastMessageTypeTools:
|
||||||
|
base := info.ClaudeConvertInfo.ToolCallBaseIndex
|
||||||
|
for offset := 0; offset <= info.ClaudeConvertInfo.ToolCallMaxIndexOffset; offset++ {
|
||||||
|
claudeResponses = append(claudeResponses, generateStopBlock(base+offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
|
||||||
|
// to the next available slot for subsequent content_block_start events.
|
||||||
|
//
|
||||||
|
// This prevents invalid streams where a content_block_delta (e.g. thinking_delta) is emitted for an
|
||||||
|
// index whose active content_block type is different (the typical cause of "Mismatched content block type").
|
||||||
|
stopOpenBlocksAndAdvance := func() {
|
||||||
|
if info.ClaudeConvertInfo.LastMessagesType == relaycommon.LastMessageTypeNone {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopOpenBlocks()
|
||||||
|
switch info.ClaudeConvertInfo.LastMessagesType {
|
||||||
|
case relaycommon.LastMessageTypeTools:
|
||||||
|
info.ClaudeConvertInfo.Index = info.ClaudeConvertInfo.ToolCallBaseIndex + info.ClaudeConvertInfo.ToolCallMaxIndexOffset + 1
|
||||||
|
info.ClaudeConvertInfo.ToolCallBaseIndex = 0
|
||||||
|
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||||
|
default:
|
||||||
|
info.ClaudeConvertInfo.Index++
|
||||||
|
}
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeNone
|
||||||
|
}
|
||||||
|
if info.SendResponseCount == 1 {
|
||||||
|
msg := &dto.ClaudeMediaMessage{
|
||||||
|
Id: openAIResponse.Id,
|
||||||
|
Model: openAIResponse.Model,
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Usage: &dto.ClaudeUsage{
|
||||||
|
InputTokens: info.GetEstimatePromptTokens(),
|
||||||
|
OutputTokens: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msg.SetContent(make([]any, 0))
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_start",
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
//claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
// Type: "ping",
|
||||||
|
//})
|
||||||
|
if openAIResponse.IsToolCall() {
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||||
|
info.ClaudeConvertInfo.ToolCallBaseIndex = 0
|
||||||
|
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||||
|
var toolCall dto.ToolCallResponse
|
||||||
|
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
|
||||||
|
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
|
||||||
|
} else {
|
||||||
|
first := openAIResponse.GetFirstToolCall()
|
||||||
|
if first != nil {
|
||||||
|
toolCall = *first
|
||||||
|
} else {
|
||||||
|
toolCall = dto.ToolCallResponse{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp := &dto.ClaudeResponse{
|
||||||
|
Type: "content_block_start",
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Id: toolCall.ID,
|
||||||
|
Type: "tool_use",
|
||||||
|
Name: toolCall.Function.Name,
|
||||||
|
Input: map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp.SetIndex(0)
|
||||||
|
claudeResponses = append(claudeResponses, resp)
|
||||||
|
// 首块包含工具 delta,则追加 input_json_delta
|
||||||
|
if toolCall.Function.Arguments != "" {
|
||||||
|
idx := 0
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "input_json_delta",
|
||||||
|
PartialJson: &toolCall.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
// 判断首个响应是否存在内容(非标准的 OpenAI 响应)
|
||||||
|
if len(openAIResponse.Choices) > 0 {
|
||||||
|
reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
|
||||||
|
content := openAIResponse.Choices[0].Delta.GetContentString()
|
||||||
|
|
||||||
|
if reasoning != "" {
|
||||||
|
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
|
||||||
|
stopOpenBlocksAndAdvance()
|
||||||
|
}
|
||||||
|
idx := info.ClaudeConvertInfo.Index
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_start",
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "thinking",
|
||||||
|
Thinking: common.GetPointer[string](""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
idx2 := idx
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx2,
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "thinking_delta",
|
||||||
|
Thinking: &reasoning,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
|
||||||
|
} else if content != "" {
|
||||||
|
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
|
||||||
|
stopOpenBlocksAndAdvance()
|
||||||
|
}
|
||||||
|
idx := info.ClaudeConvertInfo.Index
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_start",
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
idx2 := idx
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx2,
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "text_delta",
|
||||||
|
Text: common.GetPointer[string](content),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果首块就带 finish_reason,需要立即发送停止块
|
||||||
|
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
|
||||||
|
info.FinishReason = *openAIResponse.Choices[0].FinishReason
|
||||||
|
stopOpenBlocks()
|
||||||
|
oaiUsage := openAIResponse.Usage
|
||||||
|
if oaiUsage == nil {
|
||||||
|
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||||
|
}
|
||||||
|
if oaiUsage != nil {
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_delta",
|
||||||
|
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_stop",
|
||||||
|
})
|
||||||
|
info.ClaudeConvertInfo.Done = true
|
||||||
|
}
|
||||||
|
return claudeResponses
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(openAIResponse.Choices) == 0 {
|
||||||
|
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||||
|
oaiUsage := openAIResponse.Usage
|
||||||
|
if oaiUsage == nil {
|
||||||
|
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||||
|
}
|
||||||
|
if oaiUsage != nil {
|
||||||
|
stopOpenBlocks()
|
||||||
|
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
|
||||||
|
if stopReason == "" {
|
||||||
|
stopReason = "end_turn"
|
||||||
|
}
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_delta",
|
||||||
|
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
StopReason: common.GetPointer[string](stopReason),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_stop",
|
||||||
|
})
|
||||||
|
info.ClaudeConvertInfo.Done = true
|
||||||
|
}
|
||||||
|
return claudeResponses
|
||||||
|
} else {
|
||||||
|
chosenChoice := openAIResponse.Choices[0]
|
||||||
|
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
|
||||||
|
if doneChunk {
|
||||||
|
info.FinishReason = *chosenChoice.FinishReason
|
||||||
|
oaiUsage := openAIResponse.Usage
|
||||||
|
if oaiUsage == nil {
|
||||||
|
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||||
|
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
||||||
|
// Defer closing until usage is available so the final message_delta carries it.
|
||||||
|
return claudeResponses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var claudeResponse dto.ClaudeResponse
|
||||||
|
var isEmpty bool
|
||||||
|
claudeResponse.Type = "content_block_delta"
|
||||||
|
if len(chosenChoice.Delta.ToolCalls) > 0 {
|
||||||
|
toolCalls := chosenChoice.Delta.ToolCalls
|
||||||
|
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools {
|
||||||
|
stopOpenBlocksAndAdvance()
|
||||||
|
info.ClaudeConvertInfo.ToolCallBaseIndex = info.ClaudeConvertInfo.Index
|
||||||
|
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = 0
|
||||||
|
}
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools
|
||||||
|
base := info.ClaudeConvertInfo.ToolCallBaseIndex
|
||||||
|
maxOffset := info.ClaudeConvertInfo.ToolCallMaxIndexOffset
|
||||||
|
|
||||||
|
for i, toolCall := range toolCalls {
|
||||||
|
offset := 0
|
||||||
|
if toolCall.Index != nil {
|
||||||
|
offset = *toolCall.Index
|
||||||
|
} else {
|
||||||
|
offset = i
|
||||||
|
}
|
||||||
|
if offset > maxOffset {
|
||||||
|
maxOffset = offset
|
||||||
|
}
|
||||||
|
blockIndex := base + offset
|
||||||
|
|
||||||
|
idx := blockIndex
|
||||||
|
if toolCall.Function.Name != "" {
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_start",
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Id: toolCall.ID,
|
||||||
|
Type: "tool_use",
|
||||||
|
Name: toolCall.Function.Name,
|
||||||
|
Input: map[string]interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toolCall.Function.Arguments) > 0 {
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "input_json_delta",
|
||||||
|
PartialJson: &toolCall.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.ClaudeConvertInfo.ToolCallMaxIndexOffset = maxOffset
|
||||||
|
info.ClaudeConvertInfo.Index = base + maxOffset
|
||||||
|
} else {
|
||||||
|
reasoning := chosenChoice.Delta.GetReasoningContent()
|
||||||
|
textContent := chosenChoice.Delta.GetContentString()
|
||||||
|
if reasoning != "" || textContent != "" {
|
||||||
|
if reasoning != "" {
|
||||||
|
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking {
|
||||||
|
stopOpenBlocksAndAdvance()
|
||||||
|
idx := info.ClaudeConvertInfo.Index
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_start",
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "thinking",
|
||||||
|
Thinking: common.GetPointer[string](""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking
|
||||||
|
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||||
|
Type: "thinking_delta",
|
||||||
|
Thinking: &reasoning,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText {
|
||||||
|
stopOpenBlocksAndAdvance()
|
||||||
|
idx := info.ClaudeConvertInfo.Index
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Index: &idx,
|
||||||
|
Type: "content_block_start",
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer[string](""),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText
|
||||||
|
claudeResponse.Delta = &dto.ClaudeMediaMessage{
|
||||||
|
Type: "text_delta",
|
||||||
|
Text: common.GetPointer[string](textContent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isEmpty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeResponse.Index = common.GetPointer[int](info.ClaudeConvertInfo.Index)
|
||||||
|
if !isEmpty && claudeResponse.Delta != nil {
|
||||||
|
claudeResponses = append(claudeResponses, &claudeResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
if doneChunk || info.ClaudeConvertInfo.Done {
|
||||||
|
stopOpenBlocks()
|
||||||
|
oaiUsage := openAIResponse.Usage
|
||||||
|
if oaiUsage == nil {
|
||||||
|
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||||
|
}
|
||||||
|
if oaiUsage != nil {
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_delta",
|
||||||
|
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
|
Type: "message_stop",
|
||||||
|
})
|
||||||
|
info.ClaudeConvertInfo.Done = true
|
||||||
|
return claudeResponses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return claudeResponses
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse {
|
||||||
|
var stopReason string
|
||||||
|
contents := make([]dto.ClaudeMediaMessage, 0)
|
||||||
|
claudeResponse := &dto.ClaudeResponse{
|
||||||
|
Id: openAIResponse.Id,
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: openAIResponse.Model,
|
||||||
|
}
|
||||||
|
for _, choice := range openAIResponse.Choices {
|
||||||
|
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
|
||||||
|
textContent := choice.Message.StringContent()
|
||||||
|
toolCalls := choice.Message.ParseToolCalls()
|
||||||
|
if textContent != "" || len(toolCalls) == 0 {
|
||||||
|
claudeContent := dto.ClaudeMediaMessage{}
|
||||||
|
claudeContent.Type = "text"
|
||||||
|
claudeContent.SetText(textContent)
|
||||||
|
contents = append(contents, claudeContent)
|
||||||
|
}
|
||||||
|
for _, toolUse := range toolCalls {
|
||||||
|
claudeContent := dto.ClaudeMediaMessage{}
|
||||||
|
claudeContent.Type = "tool_use"
|
||||||
|
claudeContent.Id = toolUse.ID
|
||||||
|
claudeContent.Name = toolUse.Function.Name
|
||||||
|
mapParams := map[string]interface{}{}
|
||||||
|
if strings.TrimSpace(toolUse.Function.Arguments) != "" {
|
||||||
|
var parsed map[string]interface{}
|
||||||
|
if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &parsed); err == nil && parsed != nil {
|
||||||
|
mapParams = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claudeContent.Input = mapParams
|
||||||
|
contents = append(contents, claudeContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claudeResponse.Content = contents
|
||||||
|
claudeResponse.StopReason = stopReason
|
||||||
|
claudeResponse.Usage = buildClaudeUsageFromOpenAIUsage(&openAIResponse.Usage)
|
||||||
|
|
||||||
|
return claudeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopReasonOpenAI2Claude(reason string) string {
|
||||||
|
return reasonmap.OpenAIFinishReasonToClaudeStopReason(reason)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args string
|
||||||
|
want map[string]interface{}
|
||||||
|
}{
|
||||||
|
{name: "object", args: `{"q":"x"}`, want: map[string]interface{}{"q": "x"}},
|
||||||
|
{name: "empty", args: "", want: map[string]interface{}{}},
|
||||||
|
{name: "invalid", args: "{", want: map[string]interface{}{}},
|
||||||
|
{name: "null", args: "null", want: map[string]interface{}{}},
|
||||||
|
{name: "array", args: `["x"]`, want: map[string]interface{}{}},
|
||||||
|
{name: "string", args: `"x"`, want: map[string]interface{}{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
msg := dto.Message{Role: "assistant"}
|
||||||
|
msg.SetToolCalls([]dto.ToolCallRequest{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: tt.args,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{Message: msg, FinishReason: "tool_calls"},
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
require.Len(t, resp.Content, 1)
|
||||||
|
assert.Equal(t, "tool_use", resp.Content[0].Type)
|
||||||
|
assert.Equal(t, tt.want, resp.Content[0].Input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
|
||||||
|
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{Message: dto.Message{Role: "assistant", Content: "hello"}, FinishReason: "stop"},
|
||||||
|
},
|
||||||
|
Usage: dto.Usage{
|
||||||
|
PromptTokens: 11,
|
||||||
|
CompletionTokens: 5,
|
||||||
|
TotalTokens: 16,
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
require.NotNil(t, resp.Usage)
|
||||||
|
assert.Equal(t, 11, resp.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 5, resp.Usage.OutputTokens)
|
||||||
|
require.NotNil(t, resp.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, resp.Usage.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.Usage.BillingUsage.Semantic)
|
||||||
|
assert.Equal(t, 11, resp.Usage.BillingUsage.OpenAIUsage.PromptTokens)
|
||||||
|
assert.Equal(t, 5, resp.Usage.BillingUsage.OpenAIUsage.CompletionTokens)
|
||||||
|
assert.Equal(t, 16, resp.Usage.BillingUsage.OpenAIUsage.TotalTokens)
|
||||||
|
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||||
|
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
info.SendResponseCount = 1
|
||||||
|
textResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||||
|
Content: ptr("hello"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, info)
|
||||||
|
require.Len(t, textResponses, 3)
|
||||||
|
assert.Equal(t, "message_start", textResponses[0].Type)
|
||||||
|
assert.Equal(t, "content_block_start", textResponses[1].Type)
|
||||||
|
assert.Equal(t, 0, textResponses[1].GetIndex())
|
||||||
|
assert.Equal(t, "content_block_delta", textResponses[2].Type)
|
||||||
|
|
||||||
|
info.SendResponseCount = 2
|
||||||
|
thinkingResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||||
|
ReasoningContent: ptr("thinking"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, info)
|
||||||
|
require.Len(t, thinkingResponses, 3)
|
||||||
|
assert.Equal(t, "content_block_stop", thinkingResponses[0].Type)
|
||||||
|
assert.Equal(t, 0, thinkingResponses[0].GetIndex())
|
||||||
|
assert.Equal(t, "content_block_start", thinkingResponses[1].Type)
|
||||||
|
assert.Equal(t, 1, thinkingResponses[1].GetIndex())
|
||||||
|
assert.Equal(t, "thinking", thinkingResponses[1].ContentBlock.Type)
|
||||||
|
assert.Equal(t, "content_block_delta", thinkingResponses[2].Type)
|
||||||
|
|
||||||
|
info.SendResponseCount = 3
|
||||||
|
toolResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||||
|
ToolCalls: []dto.ToolCallResponse{
|
||||||
|
{
|
||||||
|
Index: ptr(0),
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: `{"q":"x"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, info)
|
||||||
|
require.Len(t, toolResponses, 3)
|
||||||
|
assert.Equal(t, "content_block_stop", toolResponses[0].Type)
|
||||||
|
assert.Equal(t, 1, toolResponses[0].GetIndex())
|
||||||
|
assert.Equal(t, "content_block_start", toolResponses[1].Type)
|
||||||
|
assert.Equal(t, 2, toolResponses[1].GetIndex())
|
||||||
|
assert.Equal(t, "tool_use", toolResponses[1].ContentBlock.Type)
|
||||||
|
assert.Equal(t, "content_block_delta", toolResponses[2].Type)
|
||||||
|
|
||||||
|
info.SendResponseCount = 4
|
||||||
|
finishResponses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{FinishReason: ptr("tool_calls")},
|
||||||
|
},
|
||||||
|
Usage: &dto.Usage{
|
||||||
|
PromptTokens: 7,
|
||||||
|
CompletionTokens: 3,
|
||||||
|
TotalTokens: 10,
|
||||||
|
},
|
||||||
|
}, info)
|
||||||
|
require.Len(t, finishResponses, 3)
|
||||||
|
assert.Equal(t, "content_block_stop", finishResponses[0].Type)
|
||||||
|
assert.Equal(t, 2, finishResponses[0].GetIndex())
|
||||||
|
assert.Equal(t, "message_delta", finishResponses[1].Type)
|
||||||
|
assert.Equal(t, "tool_use", *finishResponses[1].Delta.StopReason)
|
||||||
|
require.NotNil(t, finishResponses[1].Usage)
|
||||||
|
require.NotNil(t, finishResponses[1].Usage.BillingUsage)
|
||||||
|
require.NotNil(t, finishResponses[1].Usage.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, 7, finishResponses[1].Usage.BillingUsage.OpenAIUsage.PromptTokens)
|
||||||
|
assert.Equal(t, 3, finishResponses[1].Usage.BillingUsage.OpenAIUsage.CompletionTokens)
|
||||||
|
assert.Equal(t, "message_stop", finishResponses[2].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeCacheCreationSplit(t *testing.T) {
|
||||||
|
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
|
||||||
|
assert.Equal(t, 8, cache5m)
|
||||||
|
assert.Equal(t, 2, cache1h)
|
||||||
|
|
||||||
|
cache5m, cache1h = NormalizeCacheCreationSplit(3, 5, 1)
|
||||||
|
assert.Equal(t, 5, cache5m)
|
||||||
|
assert.Equal(t, 1, cache1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||||
|
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||||
|
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func OpenAIChatRequestToGeminiGenerateContent(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||||
|
geminiRequest := dto.GeminiChatRequest{
|
||||||
|
Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)),
|
||||||
|
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||||
|
Temperature: textRequest.Temperature,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.TopP != nil && *textRequest.TopP > 0 {
|
||||||
|
geminiRequest.GenerationConfig.TopP = common.GetPointer(*textRequest.TopP)
|
||||||
|
}
|
||||||
|
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
|
||||||
|
geminiRequest.GenerationConfig.MaxOutputTokens = common.GetPointer(maxTokens)
|
||||||
|
}
|
||||||
|
if textRequest.Seed != nil && *textRequest.Seed != 0 {
|
||||||
|
geminiRequest.GenerationConfig.Seed = common.GetPointer(int64(*textRequest.Seed))
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamModelName := textRequest.Model
|
||||||
|
if modelName := relaymeta.RelayInfoUpstreamModelName(info); modelName != "" {
|
||||||
|
upstreamModelName = modelName
|
||||||
|
}
|
||||||
|
|
||||||
|
if model_setting.IsGeminiModelSupportImagine(upstreamModelName) {
|
||||||
|
geminiRequest.GenerationConfig.ResponseModalities = []string{
|
||||||
|
"TEXT",
|
||||||
|
"IMAGE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stopSequences := sharedgemini.ParseStopSequences(textRequest.Stop); len(stopSequences) > 0 {
|
||||||
|
if len(stopSequences) > 5 {
|
||||||
|
stopSequences = stopSequences[:5]
|
||||||
|
}
|
||||||
|
geminiRequest.GenerationConfig.StopSequences = stopSequences
|
||||||
|
}
|
||||||
|
|
||||||
|
adaptorWithExtraBody := false
|
||||||
|
if len(textRequest.ExtraBody) > 0 {
|
||||||
|
var extraBody map[string]interface{}
|
||||||
|
if err := common.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid extra body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
|
||||||
|
if !strings.HasSuffix(upstreamModelName, "-nothinking") {
|
||||||
|
adaptorWithExtraBody = true
|
||||||
|
if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
|
||||||
|
return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
|
||||||
|
if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
|
||||||
|
return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
|
||||||
|
}
|
||||||
|
var hasThinkingConfig bool
|
||||||
|
var tempThinkingConfig dto.GeminiThinkingConfig
|
||||||
|
|
||||||
|
if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
|
||||||
|
switch v := thinkingBudget.(type) {
|
||||||
|
case float64:
|
||||||
|
budgetInt := int(v)
|
||||||
|
tempThinkingConfig.ThinkingBudget = common.GetPointer(budgetInt)
|
||||||
|
tempThinkingConfig.IncludeThoughts = budgetInt > 0
|
||||||
|
hasThinkingConfig = true
|
||||||
|
default:
|
||||||
|
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
|
||||||
|
if v, ok := includeThoughts.(bool); ok {
|
||||||
|
tempThinkingConfig.IncludeThoughts = v
|
||||||
|
hasThinkingConfig = true
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
|
||||||
|
if v, ok := thinkingLevel.(string); ok {
|
||||||
|
tempThinkingConfig.ThinkingLevel = v
|
||||||
|
hasThinkingConfig = true
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasThinkingConfig {
|
||||||
|
if geminiRequest.GenerationConfig.ThinkingConfig == nil {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
|
||||||
|
} else {
|
||||||
|
if tempThinkingConfig.ThinkingBudget != nil {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = tempThinkingConfig.ThinkingBudget
|
||||||
|
}
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = tempThinkingConfig.IncludeThoughts
|
||||||
|
if tempThinkingConfig.ThinkingLevel != "" {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingLevel = tempThinkingConfig.ThinkingLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasErrorParam := googleBody["imageConfig"]; hasErrorParam {
|
||||||
|
return nil, errors.New("extra_body.google.imageConfig is not supported, use extra_body.google.image_config instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
if imageConfig, ok := googleBody["image_config"].(map[string]interface{}); ok {
|
||||||
|
if _, hasErrorParam := imageConfig["aspectRatio"]; hasErrorParam {
|
||||||
|
return nil, errors.New("extra_body.google.image_config.aspectRatio is not supported, use extra_body.google.image_config.aspect_ratio instead")
|
||||||
|
}
|
||||||
|
if _, hasErrorParam := imageConfig["imageSize"]; hasErrorParam {
|
||||||
|
return nil, errors.New("extra_body.google.image_config.imageSize is not supported, use extra_body.google.image_config.image_size instead")
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiImageConfig := make(map[string]interface{})
|
||||||
|
if aspectRatio, ok := imageConfig["aspect_ratio"]; ok {
|
||||||
|
geminiImageConfig["aspectRatio"] = aspectRatio
|
||||||
|
}
|
||||||
|
if imageSize, ok := imageConfig["image_size"]; ok {
|
||||||
|
geminiImageConfig["imageSize"] = imageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(geminiImageConfig) > 0 {
|
||||||
|
imageConfigBytes, err := common.Marshal(geminiImageConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal image_config: %w", err)
|
||||||
|
}
|
||||||
|
geminiRequest.GenerationConfig.ImageConfig = imageConfigBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !adaptorWithExtraBody {
|
||||||
|
sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(sharedgemini.SafetySettingCategories))
|
||||||
|
for _, category := range sharedgemini.SafetySettingCategories {
|
||||||
|
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
|
||||||
|
Category: category,
|
||||||
|
Threshold: model_setting.GetGeminiSafetySetting(category),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
geminiRequest.SafetySettings = safetySettings
|
||||||
|
|
||||||
|
if textRequest.Tools != nil {
|
||||||
|
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
||||||
|
googleSearch := false
|
||||||
|
codeExecution := false
|
||||||
|
urlContext := false
|
||||||
|
for _, tool := range textRequest.Tools {
|
||||||
|
if tool.Function.Name == "googleSearch" {
|
||||||
|
googleSearch = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tool.Function.Name == "codeExecution" {
|
||||||
|
codeExecution = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tool.Function.Name == "urlContext" {
|
||||||
|
urlContext = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tool.Function.Parameters != nil {
|
||||||
|
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
|
||||||
|
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
|
||||||
|
tool.Function.Parameters = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tool.Function.Parameters = sharedgemini.CleanFunctionParameters(tool.Function.Parameters)
|
||||||
|
functions = append(functions, tool.Function)
|
||||||
|
}
|
||||||
|
geminiTools := geminiRequest.GetTools()
|
||||||
|
if codeExecution {
|
||||||
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
|
CodeExecution: make(map[string]string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if googleSearch {
|
||||||
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
|
GoogleSearch: make(map[string]string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if urlContext {
|
||||||
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
|
URLContext: make(map[string]string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(functions) > 0 {
|
||||||
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
|
FunctionDeclarations: functions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
geminiRequest.SetTools(geminiTools)
|
||||||
|
|
||||||
|
if textRequest.ToolChoice != nil {
|
||||||
|
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(textRequest.ToolChoice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
|
||||||
|
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
|
||||||
|
|
||||||
|
if len(textRequest.ResponseFormat.JsonSchema) > 0 {
|
||||||
|
var jsonSchema dto.FormatJsonSchema
|
||||||
|
if err := common.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
|
||||||
|
cleanedSchema := sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
|
||||||
|
geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCallIDs := make(map[string]string)
|
||||||
|
var systemContent []string
|
||||||
|
for _, message := range textRequest.Messages {
|
||||||
|
if message.Role == "system" || message.Role == "developer" {
|
||||||
|
systemContent = append(systemContent, message.StringContent())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if message.Role == "tool" || message.Role == "function" {
|
||||||
|
if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" {
|
||||||
|
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
|
||||||
|
Role: "user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
parts := &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts
|
||||||
|
name := ""
|
||||||
|
if message.Name != nil {
|
||||||
|
name = *message.Name
|
||||||
|
} else if val, exists := toolCallIDs[message.ToolCallId]; exists {
|
||||||
|
name = val
|
||||||
|
}
|
||||||
|
var contentMap map[string]interface{}
|
||||||
|
contentStr := message.StringContent()
|
||||||
|
|
||||||
|
if err := common.Unmarshal([]byte(contentStr), &contentMap); err != nil {
|
||||||
|
var contentSlice []interface{}
|
||||||
|
if err := common.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
|
||||||
|
contentMap = map[string]interface{}{"result": contentSlice}
|
||||||
|
} else {
|
||||||
|
contentMap = map[string]interface{}{"content": contentStr}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
functionResp := &dto.GeminiFunctionResponse{
|
||||||
|
Name: name,
|
||||||
|
Response: contentMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
*parts = append(*parts, dto.GeminiPart{
|
||||||
|
FunctionResponse: functionResp,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []dto.GeminiPart
|
||||||
|
content := dto.GeminiChatContent{
|
||||||
|
Role: message.Role,
|
||||||
|
}
|
||||||
|
shouldAttachThoughtSignature := (message.Role == "assistant" || message.Role == "model") && sharedgemini.ShouldAttachThoughtSignature()
|
||||||
|
signatureAttached := false
|
||||||
|
if message.ToolCalls != nil {
|
||||||
|
for _, call := range message.ParseToolCalls() {
|
||||||
|
args := map[string]interface{}{}
|
||||||
|
if call.Function.Arguments != "" {
|
||||||
|
if common.Unmarshal([]byte(call.Function.Arguments), &args) != nil {
|
||||||
|
return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolCall := dto.GeminiPart{
|
||||||
|
FunctionCall: &dto.FunctionCall{
|
||||||
|
FunctionName: call.Function.Name,
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if shouldAttachThoughtSignature && !signatureAttached && sharedgemini.AttachFunctionCallThoughtSignature(&toolCall) {
|
||||||
|
signatureAttached = true
|
||||||
|
}
|
||||||
|
parts = append(parts, toolCall)
|
||||||
|
toolCallIDs[call.ID] = call.Function.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openaiContent := message.ParseContent()
|
||||||
|
for _, part := range openaiContent {
|
||||||
|
if part.Type == dto.ContentTypeText {
|
||||||
|
if part.Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text := part.Text
|
||||||
|
hasMarkdownImage := false
|
||||||
|
for {
|
||||||
|
startIdx := strings.Index(text, "![")
|
||||||
|
if startIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bracketIdx := strings.Index(text[startIdx:], "](data:")
|
||||||
|
if bracketIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bracketIdx += startIdx
|
||||||
|
closeIdx := strings.Index(text[bracketIdx+2:], ")")
|
||||||
|
if closeIdx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
closeIdx += bracketIdx + 2
|
||||||
|
|
||||||
|
hasMarkdownImage = true
|
||||||
|
if startIdx > 0 {
|
||||||
|
textBefore := text[:startIdx]
|
||||||
|
if textBefore != "" {
|
||||||
|
parts = append(parts, dto.GeminiPart{
|
||||||
|
Text: textBefore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataURL := text[bracketIdx+2 : closeIdx]
|
||||||
|
format, base64String, err := relaymedia.DecodeBase64FileData(dataURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
imgPart := dto.GeminiPart{
|
||||||
|
InlineData: &dto.GeminiInlineData{
|
||||||
|
MimeType: format,
|
||||||
|
Data: base64String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if shouldAttachThoughtSignature {
|
||||||
|
sharedgemini.AttachThoughtSignatureBypass(&imgPart)
|
||||||
|
}
|
||||||
|
parts = append(parts, imgPart)
|
||||||
|
text = text[closeIdx+1:]
|
||||||
|
}
|
||||||
|
if !hasMarkdownImage {
|
||||||
|
parts = append(parts, dto.GeminiPart{
|
||||||
|
Text: part.Text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
source := part.ToFileSource()
|
||||||
|
if source == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting image for Gemini")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||||
|
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
|
||||||
|
}
|
||||||
|
|
||||||
|
parts = append(parts, dto.GeminiPart{
|
||||||
|
InlineData: &dto.GeminiInlineData{
|
||||||
|
MimeType: mimeType,
|
||||||
|
Data: base64Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 {
|
||||||
|
sharedgemini.AttachFirstTextThoughtSignature(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Parts = parts
|
||||||
|
if content.Role == "assistant" {
|
||||||
|
content.Role = "model"
|
||||||
|
}
|
||||||
|
if len(content.Parts) > 0 {
|
||||||
|
geminiRequest.Contents = append(geminiRequest.Contents, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(systemContent) > 0 {
|
||||||
|
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{
|
||||||
|
Text: strings.Join(systemContent, "\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &geminiRequest, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
|
||||||
|
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||||
|
totalTokens := openAIResponse.TotalTokens
|
||||||
|
if totalTokens == 0 {
|
||||||
|
totalTokens = openAIResponse.PromptTokens + openAIResponse.CompletionTokens
|
||||||
|
}
|
||||||
|
geminiResponse := &dto.GeminiChatResponse{
|
||||||
|
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
|
||||||
|
HasUsageMetadata: true,
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: openAIResponse.PromptTokens,
|
||||||
|
CandidatesTokenCount: openAIResponse.CompletionTokens,
|
||||||
|
TotalTokenCount: totalTokens,
|
||||||
|
BillingUsage: openAIBillingUsageFromUsage(&openAIResponse.Usage),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(&openAIResponse.Usage); ok {
|
||||||
|
geminiResponse.UsageMetadata = metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, choice := range openAIResponse.Choices {
|
||||||
|
candidate := dto.GeminiChatCandidate{
|
||||||
|
Index: int64(choice.Index),
|
||||||
|
SafetyRatings: []dto.GeminiChatSafetyRating{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置结束原因
|
||||||
|
var finishReason string
|
||||||
|
switch choice.FinishReason {
|
||||||
|
case "stop":
|
||||||
|
finishReason = "STOP"
|
||||||
|
case "length":
|
||||||
|
finishReason = "MAX_TOKENS"
|
||||||
|
case "content_filter":
|
||||||
|
finishReason = "SAFETY"
|
||||||
|
case "tool_calls":
|
||||||
|
finishReason = "STOP"
|
||||||
|
default:
|
||||||
|
finishReason = "STOP"
|
||||||
|
}
|
||||||
|
candidate.FinishReason = &finishReason
|
||||||
|
|
||||||
|
// 转换消息内容
|
||||||
|
content := dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: make([]dto.GeminiPart, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
textContent := choice.Message.StringContent()
|
||||||
|
if textContent != "" {
|
||||||
|
part := dto.GeminiPart{
|
||||||
|
Text: textContent,
|
||||||
|
}
|
||||||
|
content.Parts = append(content.Parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls := choice.Message.ParseToolCalls()
|
||||||
|
for _, toolCall := range toolCalls {
|
||||||
|
var args map[string]interface{}
|
||||||
|
if toolCall.Function.Arguments != "" {
|
||||||
|
if err := common.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||||
|
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
part := dto.GeminiPart{
|
||||||
|
FunctionCall: &dto.FunctionCall{
|
||||||
|
FunctionName: toolCall.Function.Name,
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
content.Parts = append(content.Parts, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.Content = content
|
||||||
|
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return geminiResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式
|
||||||
|
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||||
|
// 检查是否有实际内容或结束标志
|
||||||
|
hasContent := false
|
||||||
|
hasFinishReason := false
|
||||||
|
for _, choice := range openAIResponse.Choices {
|
||||||
|
if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) {
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
if choice.FinishReason != nil {
|
||||||
|
hasFinishReason = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据
|
||||||
|
if !hasContent && !hasFinishReason {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatePromptTokens := 0
|
||||||
|
if info != nil {
|
||||||
|
estimatePromptTokens = info.GetEstimatePromptTokens()
|
||||||
|
}
|
||||||
|
geminiResponse := &dto.GeminiChatResponse{
|
||||||
|
Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
|
||||||
|
HasUsageMetadata: true,
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: estimatePromptTokens,
|
||||||
|
CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
|
||||||
|
TotalTokenCount: estimatePromptTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if openAIResponse.Usage != nil {
|
||||||
|
geminiResponse.UsageMetadata.PromptTokenCount = openAIResponse.Usage.PromptTokens
|
||||||
|
geminiResponse.UsageMetadata.CandidatesTokenCount = openAIResponse.Usage.CompletionTokens
|
||||||
|
geminiResponse.UsageMetadata.TotalTokenCount = openAIResponse.Usage.TotalTokens
|
||||||
|
geminiResponse.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(openAIResponse.Usage)
|
||||||
|
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(openAIResponse.Usage); ok {
|
||||||
|
geminiResponse.UsageMetadata = metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, choice := range openAIResponse.Choices {
|
||||||
|
candidate := dto.GeminiChatCandidate{
|
||||||
|
Index: int64(choice.Index),
|
||||||
|
SafetyRatings: []dto.GeminiChatSafetyRating{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置结束原因
|
||||||
|
if choice.FinishReason != nil {
|
||||||
|
var finishReason string
|
||||||
|
switch *choice.FinishReason {
|
||||||
|
case "stop":
|
||||||
|
finishReason = "STOP"
|
||||||
|
case "length":
|
||||||
|
finishReason = "MAX_TOKENS"
|
||||||
|
case "content_filter":
|
||||||
|
finishReason = "SAFETY"
|
||||||
|
case "tool_calls":
|
||||||
|
finishReason = "STOP"
|
||||||
|
default:
|
||||||
|
finishReason = "STOP"
|
||||||
|
}
|
||||||
|
candidate.FinishReason = &finishReason
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换消息内容
|
||||||
|
content := dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: make([]dto.GeminiPart, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理工具调用
|
||||||
|
if choice.Delta.ToolCalls != nil {
|
||||||
|
for _, toolCall := range choice.Delta.ToolCalls {
|
||||||
|
// 解析参数
|
||||||
|
var args map[string]interface{}
|
||||||
|
if toolCall.Function.Arguments != "" {
|
||||||
|
if err := common.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
|
||||||
|
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
part := dto.GeminiPart{
|
||||||
|
FunctionCall: &dto.FunctionCall{
|
||||||
|
FunctionName: toolCall.Function.Name,
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
content.Parts = append(content.Parts, part)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 处理文本内容
|
||||||
|
textContent := choice.Delta.GetContentString()
|
||||||
|
if textContent != "" {
|
||||||
|
part := dto.GeminiPart{
|
||||||
|
Text: textContent,
|
||||||
|
}
|
||||||
|
content.Parts = append(content.Parts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate.Content = content
|
||||||
|
geminiResponse.Candidates = append(geminiResponse.Candidates, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return geminiResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMetadata, bool) {
|
||||||
|
if usage == nil || usage.BillingUsage == nil || usage.BillingUsage.GeminiUsageMetadata == nil {
|
||||||
|
return dto.GeminiUsageMetadata{}, false
|
||||||
|
}
|
||||||
|
if usage.BillingUsage.Source != dto.BillingUsageSourceGeminiChat && usage.BillingUsage.Semantic != dto.BillingUsageSemanticGemini {
|
||||||
|
return dto.GeminiUsageMetadata{}, false
|
||||||
|
}
|
||||||
|
billingUsage := dto.CloneBillingUsage(usage.BillingUsage)
|
||||||
|
if billingUsage == nil || billingUsage.GeminiUsageMetadata == nil {
|
||||||
|
return dto.GeminiUsageMetadata{}, false
|
||||||
|
}
|
||||||
|
return *billingUsage.GeminiUsageMetadata, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
|
||||||
|
if usage == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
|
||||||
|
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
|
||||||
|
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
|
||||||
|
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
|
||||||
|
return existingBillingUsage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dto.NewOpenAIChatBillingUsage(usage)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponseOpenAI2GeminiMapsTextToolFinishReasonAndUsage(t *testing.T) {
|
||||||
|
msg := dto.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
msg.SetToolCalls([]dto.ToolCallRequest{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: `{"q":"x"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := ResponseOpenAI2Gemini(&dto.OpenAITextResponse{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{
|
||||||
|
Index: 2,
|
||||||
|
Message: msg,
|
||||||
|
FinishReason: "length",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: dto.Usage{
|
||||||
|
PromptTokens: 11,
|
||||||
|
CompletionTokens: 5,
|
||||||
|
TotalTokens: 16,
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
assert.Equal(t, 11, resp.UsageMetadata.PromptTokenCount)
|
||||||
|
assert.Equal(t, 5, resp.UsageMetadata.CandidatesTokenCount)
|
||||||
|
assert.Equal(t, 16, resp.UsageMetadata.TotalTokenCount)
|
||||||
|
require.NotNil(t, resp.UsageMetadata.BillingUsage)
|
||||||
|
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceOAIChat, resp.UsageMetadata.BillingUsage.Source)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticOpenAI, resp.UsageMetadata.BillingUsage.Semantic)
|
||||||
|
assert.Equal(t, 11, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
|
||||||
|
assert.Equal(t, 5, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
|
||||||
|
assert.Equal(t, 16, resp.UsageMetadata.BillingUsage.OpenAIUsage.TotalTokens)
|
||||||
|
assert.Nil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage.BillingUsage)
|
||||||
|
require.Len(t, resp.Candidates, 1)
|
||||||
|
assert.Equal(t, int64(2), resp.Candidates[0].Index)
|
||||||
|
require.NotNil(t, resp.Candidates[0].FinishReason)
|
||||||
|
assert.Equal(t, "MAX_TOKENS", *resp.Candidates[0].FinishReason)
|
||||||
|
require.Len(t, resp.Candidates[0].Content.Parts, 2)
|
||||||
|
assert.Equal(t, "hello", resp.Candidates[0].Content.Parts[0].Text)
|
||||||
|
require.NotNil(t, resp.Candidates[0].Content.Parts[1].FunctionCall)
|
||||||
|
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
|
||||||
|
assert.Equal(t, map[string]interface{}{"q": "x"}, resp.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseOpenAI2GeminiMapsToolCallFinishReasonAndUsage(t *testing.T) {
|
||||||
|
resp := StreamResponseOpenAI2Gemini(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{
|
||||||
|
Index: 1,
|
||||||
|
FinishReason: geminiRespPtr("tool_calls"),
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||||
|
ToolCalls: []dto.ToolCallResponse{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: `{"q":"x"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: &dto.Usage{
|
||||||
|
PromptTokens: 13,
|
||||||
|
CompletionTokens: 8,
|
||||||
|
TotalTokens: 21,
|
||||||
|
},
|
||||||
|
}, &relaycommon.RelayInfo{})
|
||||||
|
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.Equal(t, 13, resp.UsageMetadata.PromptTokenCount)
|
||||||
|
assert.Equal(t, 8, resp.UsageMetadata.CandidatesTokenCount)
|
||||||
|
assert.Equal(t, 21, resp.UsageMetadata.TotalTokenCount)
|
||||||
|
require.NotNil(t, resp.UsageMetadata.BillingUsage)
|
||||||
|
require.NotNil(t, resp.UsageMetadata.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, 13, resp.UsageMetadata.BillingUsage.OpenAIUsage.PromptTokens)
|
||||||
|
assert.Equal(t, 8, resp.UsageMetadata.BillingUsage.OpenAIUsage.CompletionTokens)
|
||||||
|
require.Len(t, resp.Candidates, 1)
|
||||||
|
assert.Equal(t, int64(1), resp.Candidates[0].Index)
|
||||||
|
require.NotNil(t, resp.Candidates[0].FinishReason)
|
||||||
|
assert.Equal(t, "STOP", *resp.Candidates[0].FinishReason)
|
||||||
|
require.Len(t, resp.Candidates[0].Content.Parts, 1)
|
||||||
|
require.NotNil(t, resp.Candidates[0].Content.Parts[0].FunctionCall)
|
||||||
|
assert.Equal(t, "lookup", resp.Candidates[0].Content.Parts[0].FunctionCall.FunctionName)
|
||||||
|
assert.Equal(t, map[string]interface{}{"q": "x"}, resp.Candidates[0].Content.Parts[0].FunctionCall.Arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func geminiRespPtr[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
+6
-3
@@ -1,12 +1,15 @@
|
|||||||
package relayconvert
|
package oaichat
|
||||||
|
|
||||||
import "github.com/QuantumNous/new-api/setting/model_setting"
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert/internal/matcher"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
)
|
||||||
|
|
||||||
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||||
if !policy.IsChannelEnabled(channelID, channelType) {
|
if !policy.IsChannelEnabled(channelID, channelType) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return matchAnyRegex(policy.ModelPatterns, model)
|
return matcher.MatchAnyRegex(policy.ModelPatterns, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package relayconvert
|
package oaichat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing.T) {
|
||||||
|
req := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
N: lo.ToPtr(1),
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "system", Content: "system rules"},
|
||||||
|
{Role: "developer", Content: "developer rules"},
|
||||||
|
{Role: "user", Content: []any{
|
||||||
|
map[string]any{"type": "text", "text": "look"},
|
||||||
|
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/a.png"}},
|
||||||
|
}},
|
||||||
|
assistantMessageWithTool("partial text", "call_1", "lookup", `{"q":"x"}`),
|
||||||
|
{Role: "tool", ToolCallId: "call_1", Content: "tool result"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ChatCompletionsRequestToResponsesRequest(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "gpt-test", got.Model)
|
||||||
|
assert.Equal(t, `"system rules\n\ndeveloper rules"`, string(got.Instructions))
|
||||||
|
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.1.type").String())
|
||||||
|
assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "2.type").String())
|
||||||
|
assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "2.call_id").String())
|
||||||
|
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
|
||||||
|
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
N: lo.ToPtr(2),
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "n>1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
|
||||||
|
msg := dto.Message{Role: "assistant", Content: content}
|
||||||
|
msg.SetToolCalls([]dto.ToolCallRequest{
|
||||||
|
{
|
||||||
|
ID: id,
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: name,
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
chatFinishReasonLength = "length"
|
||||||
|
chatFinishReasonContentFilter = "content_filter"
|
||||||
|
|
||||||
|
responsesEventCreated = "response.created"
|
||||||
|
responsesEventCompleted = "response.completed"
|
||||||
|
responsesEventIncomplete = "response.incomplete"
|
||||||
|
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||||
|
responsesEventOutputItemAdded = "response.output_item.added"
|
||||||
|
responsesEventOutputItemDone = "response.output_item.done"
|
||||||
|
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||||
|
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||||
|
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||||
|
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||||
|
responsesOutputTypeFunctionCall = "function_call"
|
||||||
|
responsesOutputTypeMessage = "message"
|
||||||
|
responsesOutputTypeReasoning = "reasoning"
|
||||||
|
responsesIncompleteReasonContentFilter = "content_filter"
|
||||||
|
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||||
|
if resp == nil {
|
||||||
|
return nil, nil, errors.New("response is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := UsageFromChatUsage(&resp.Usage)
|
||||||
|
out := &dto.OpenAIResponsesResponse{
|
||||||
|
ID: id,
|
||||||
|
Object: "response",
|
||||||
|
CreatedAt: chatCreatedAt(resp.Created),
|
||||||
|
Status: []byte(`"completed"`),
|
||||||
|
Model: resp.Model,
|
||||||
|
Output: make([]dto.ResponsesOutput, 0),
|
||||||
|
Usage: usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return out, usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := resp.Choices[0]
|
||||||
|
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
|
||||||
|
out.Status = []byte(fmt.Sprintf("%q", status))
|
||||||
|
out.IncompleteDetails = details
|
||||||
|
}
|
||||||
|
|
||||||
|
if text := choice.Message.StringContent(); text != "" {
|
||||||
|
out.Output = append(out.Output, dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeMessage,
|
||||||
|
ID: fmt.Sprintf("%s_msg_0", id),
|
||||||
|
Status: responseOutputStatus(out),
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{
|
||||||
|
Type: "output_text",
|
||||||
|
Text: text,
|
||||||
|
Annotations: []interface{}{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
|
||||||
|
out.Output = append(out.Output, dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeReasoning,
|
||||||
|
ID: fmt.Sprintf("%s_reasoning_0", id),
|
||||||
|
Status: responseOutputStatus(out),
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{
|
||||||
|
Type: "summary_text",
|
||||||
|
Text: reasoning,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, toolCall := range choice.Message.ParseToolCalls() {
|
||||||
|
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
out.Output = append(out.Output, toolOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
|
||||||
|
switch strings.TrimSpace(finishReason) {
|
||||||
|
case chatFinishReasonLength:
|
||||||
|
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
|
||||||
|
case chatFinishReasonContentFilter:
|
||||||
|
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
|
||||||
|
default:
|
||||||
|
return "completed", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
||||||
|
usage := &dto.Usage{}
|
||||||
|
if src == nil {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
usage.UsageSemantic = src.UsageSemantic
|
||||||
|
usage.UsageSource = src.UsageSource
|
||||||
|
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
|
||||||
|
if usage.BillingUsage == nil {
|
||||||
|
usage.BillingUsage = dto.NewOpenAIChatBillingUsage(src)
|
||||||
|
}
|
||||||
|
usage.Cost = src.Cost
|
||||||
|
if src.PromptTokens != 0 {
|
||||||
|
usage.PromptTokens = src.PromptTokens
|
||||||
|
usage.InputTokens = src.PromptTokens
|
||||||
|
}
|
||||||
|
if src.CompletionTokens != 0 {
|
||||||
|
usage.CompletionTokens = src.CompletionTokens
|
||||||
|
usage.OutputTokens = src.CompletionTokens
|
||||||
|
}
|
||||||
|
if src.TotalTokens != 0 {
|
||||||
|
usage.TotalTokens = src.TotalTokens
|
||||||
|
} else {
|
||||||
|
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
|
||||||
|
}
|
||||||
|
if src.PromptTokensDetails.CachedTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.ImageTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.AudioTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
||||||
|
src.PromptTokensDetails.TextTokens != 0 {
|
||||||
|
details := src.PromptTokensDetails
|
||||||
|
usage.InputTokensDetails = &details
|
||||||
|
}
|
||||||
|
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.TextTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.AudioTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.ImageTokens != 0 {
|
||||||
|
usage.CompletionTokenDetails = src.CompletionTokenDetails
|
||||||
|
}
|
||||||
|
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
|
||||||
|
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
if resp == nil || responseStatusString(resp) != "incomplete" {
|
||||||
|
return "completed"
|
||||||
|
}
|
||||||
|
return "incomplete"
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
if resp == nil || len(resp.Status) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
_ = common.Unmarshal(resp.Status, &status)
|
||||||
|
return strings.TrimSpace(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
|
||||||
|
callID := strings.TrimSpace(toolCall.ID)
|
||||||
|
if callID == "" {
|
||||||
|
callID = fmt.Sprintf("%s_call_%d", responseID, index)
|
||||||
|
}
|
||||||
|
if toolCall.Type == "" || toolCall.Type == "function" {
|
||||||
|
return dto.ResponsesOutput{
|
||||||
|
Type: responsesOutputTypeFunctionCall,
|
||||||
|
ID: callID,
|
||||||
|
Status: status,
|
||||||
|
CallId: callID,
|
||||||
|
Name: toolCall.Function.Name,
|
||||||
|
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return dto.ResponsesOutput{
|
||||||
|
Type: toolCall.Type,
|
||||||
|
ID: callID,
|
||||||
|
Status: status,
|
||||||
|
CallId: callID,
|
||||||
|
Arguments: toolCall.Custom,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatArgumentsRawMessage(arguments string) []byte {
|
||||||
|
raw, err := common.Marshal(arguments)
|
||||||
|
if err != nil {
|
||||||
|
return []byte(`""`)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatCreatedAt(created any) int {
|
||||||
|
switch v := created.(type) {
|
||||||
|
case int:
|
||||||
|
return v
|
||||||
|
case int64:
|
||||||
|
return int(v)
|
||||||
|
case float64:
|
||||||
|
return int(v)
|
||||||
|
case float32:
|
||||||
|
return int(v)
|
||||||
|
case string:
|
||||||
|
if parsed := common.String2Int(v); parsed != 0 {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return int(time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
|
||||||
|
payload.Type = eventType
|
||||||
|
return ChatToResponsesStreamEvent{
|
||||||
|
Type: eventType,
|
||||||
|
Payload: payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtr(v int) *int {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package oaichat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *testing.T) {
|
||||||
|
chat := &dto.OpenAITextResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Created: 456,
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{
|
||||||
|
Message: assistantMessageWithTool("I will call.", "call_1", "lookup", `{"q":"x"}`),
|
||||||
|
FinishReason: "tool_calls",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
|
||||||
|
assert.Equal(t, "resp_1", resp.ID)
|
||||||
|
assert.Equal(t, "response", resp.Object)
|
||||||
|
assert.Equal(t, `"completed"`, string(resp.Status))
|
||||||
|
assert.Equal(t, 3, resp.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 5, resp.Usage.OutputTokens)
|
||||||
|
require.Len(t, resp.Output, 2)
|
||||||
|
assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type)
|
||||||
|
assert.Equal(t, "I will call.", resp.Output[0].Content[0].Text)
|
||||||
|
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
|
||||||
|
assert.Equal(t, "call_1", resp.Output[1].CallId)
|
||||||
|
assert.Equal(t, "lookup", resp.Output[1].Name)
|
||||||
|
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
finishReason string
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{name: "length", finishReason: "length", wantReason: responsesIncompleteReasonMaxTokens},
|
||||||
|
{name: "content filter", finishReason: "content_filter", wantReason: responsesIncompleteReasonContentFilter},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{
|
||||||
|
Message: dto.Message{Role: "assistant", Content: "partial"},
|
||||||
|
FinishReason: tt.finishReason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, "resp_1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, `"incomplete"`, string(resp.Status))
|
||||||
|
require.NotNil(t, resp.IncompleteDetails)
|
||||||
|
assert.Equal(t, tt.wantReason, resp.IncompleteDetails.Reason)
|
||||||
|
require.Len(t, resp.Output, 1)
|
||||||
|
assert.Equal(t, "incomplete", resp.Output[0].Status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *testing.T) {
|
||||||
|
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
||||||
|
state.Created = 123
|
||||||
|
toolIndex := 0
|
||||||
|
|
||||||
|
var events []ChatToResponsesStreamEvent
|
||||||
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Created: 123,
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}},
|
||||||
|
},
|
||||||
|
})...)
|
||||||
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}},
|
||||||
|
},
|
||||||
|
})...)
|
||||||
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
||||||
|
{Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
})...)
|
||||||
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
||||||
|
{Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
})...)
|
||||||
|
finishReason := "tool_calls"
|
||||||
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{Index: 0, FinishReason: &finishReason},
|
||||||
|
},
|
||||||
|
})...)
|
||||||
|
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6},
|
||||||
|
})...)
|
||||||
|
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
||||||
|
|
||||||
|
require.Len(t, events, 10)
|
||||||
|
assert.Equal(t, responsesEventCreated, events[0].Type)
|
||||||
|
assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
|
||||||
|
assert.Equal(t, "hello", events[2].Payload.Delta)
|
||||||
|
assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
|
||||||
|
assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
|
||||||
|
assert.Equal(t, responsesEventCompleted, events[9].Type)
|
||||||
|
require.NotNil(t, events[9].Payload.Response)
|
||||||
|
assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
|
||||||
|
require.Len(t, events[9].Payload.Response.Output, 2)
|
||||||
|
assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
|
||||||
|
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
||||||
|
t.Helper()
|
||||||
|
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return events
|
||||||
|
}
|
||||||
+1
-189
@@ -1,133 +1,14 @@
|
|||||||
package relayconvert
|
package oaichat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
chatFinishReasonLength = "length"
|
|
||||||
chatFinishReasonContentFilter = "content_filter"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
|
||||||
if resp == nil {
|
|
||||||
return nil, nil, errors.New("response is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
usage := UsageFromChatUsage(&resp.Usage)
|
|
||||||
out := &dto.OpenAIResponsesResponse{
|
|
||||||
ID: id,
|
|
||||||
Object: "response",
|
|
||||||
CreatedAt: chatCreatedAt(resp.Created),
|
|
||||||
Status: []byte(`"completed"`),
|
|
||||||
Model: resp.Model,
|
|
||||||
Output: make([]dto.ResponsesOutput, 0),
|
|
||||||
Usage: usage,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.Choices) == 0 {
|
|
||||||
return out, usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
choice := resp.Choices[0]
|
|
||||||
if status, details := ResponsesStatusFromChatFinishReason(choice.FinishReason); status != "" {
|
|
||||||
out.Status = []byte(fmt.Sprintf("%q", status))
|
|
||||||
out.IncompleteDetails = details
|
|
||||||
}
|
|
||||||
|
|
||||||
if text := choice.Message.StringContent(); text != "" {
|
|
||||||
out.Output = append(out.Output, dto.ResponsesOutput{
|
|
||||||
Type: responsesOutputTypeMessage,
|
|
||||||
ID: fmt.Sprintf("%s_msg_0", id),
|
|
||||||
Status: responseOutputStatus(out),
|
|
||||||
Role: "assistant",
|
|
||||||
Content: []dto.ResponsesOutputContent{
|
|
||||||
{
|
|
||||||
Type: "output_text",
|
|
||||||
Text: text,
|
|
||||||
Annotations: []interface{}{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
|
|
||||||
out.Output = append(out.Output, dto.ResponsesOutput{
|
|
||||||
Type: responsesOutputTypeReasoning,
|
|
||||||
ID: fmt.Sprintf("%s_reasoning_0", id),
|
|
||||||
Status: responseOutputStatus(out),
|
|
||||||
Content: []dto.ResponsesOutputContent{
|
|
||||||
{
|
|
||||||
Type: "summary_text",
|
|
||||||
Text: reasoning,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, toolCall := range choice.Message.ParseToolCalls() {
|
|
||||||
toolOutput, err := chatToolCallToResponsesOutput(toolCall, id, i, responseOutputStatus(out))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
out.Output = append(out.Output, toolOutput)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
|
|
||||||
switch strings.TrimSpace(finishReason) {
|
|
||||||
case chatFinishReasonLength:
|
|
||||||
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonMaxTokens}
|
|
||||||
case chatFinishReasonContentFilter:
|
|
||||||
return "incomplete", &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter}
|
|
||||||
default:
|
|
||||||
return "completed", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
|
||||||
usage := &dto.Usage{}
|
|
||||||
if src == nil {
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
if src.PromptTokens != 0 {
|
|
||||||
usage.PromptTokens = src.PromptTokens
|
|
||||||
usage.InputTokens = src.PromptTokens
|
|
||||||
}
|
|
||||||
if src.CompletionTokens != 0 {
|
|
||||||
usage.CompletionTokens = src.CompletionTokens
|
|
||||||
usage.OutputTokens = src.CompletionTokens
|
|
||||||
}
|
|
||||||
if src.TotalTokens != 0 {
|
|
||||||
usage.TotalTokens = src.TotalTokens
|
|
||||||
} else {
|
|
||||||
usage.TotalTokens = usage.InputTokens + usage.OutputTokens
|
|
||||||
}
|
|
||||||
if src.PromptTokensDetails.CachedTokens != 0 ||
|
|
||||||
src.PromptTokensDetails.ImageTokens != 0 ||
|
|
||||||
src.PromptTokensDetails.AudioTokens != 0 ||
|
|
||||||
src.PromptTokensDetails.CachedCreationTokens != 0 ||
|
|
||||||
src.PromptTokensDetails.TextTokens != 0 {
|
|
||||||
details := src.PromptTokensDetails
|
|
||||||
usage.InputTokensDetails = &details
|
|
||||||
}
|
|
||||||
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
|
||||||
src.CompletionTokenDetails.TextTokens != 0 ||
|
|
||||||
src.CompletionTokenDetails.AudioTokens != 0 ||
|
|
||||||
src.CompletionTokenDetails.ImageTokens != 0 {
|
|
||||||
usage.CompletionTokenDetails = src.CompletionTokenDetails
|
|
||||||
}
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatToResponsesStreamEvent struct {
|
type ChatToResponsesStreamEvent struct {
|
||||||
Type string
|
Type string
|
||||||
Payload dto.ResponsesStreamResponse
|
Payload dto.ResponsesStreamResponse
|
||||||
@@ -534,72 +415,3 @@ func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool,
|
|||||||
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func responseOutputStatus(resp *dto.OpenAIResponsesResponse) string {
|
|
||||||
if resp == nil || responseStatusString(resp) != "incomplete" {
|
|
||||||
return "completed"
|
|
||||||
}
|
|
||||||
return "incomplete"
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatToolCallToResponsesOutput(toolCall dto.ToolCallRequest, responseID string, index int, status string) (dto.ResponsesOutput, error) {
|
|
||||||
callID := strings.TrimSpace(toolCall.ID)
|
|
||||||
if callID == "" {
|
|
||||||
callID = fmt.Sprintf("%s_call_%d", responseID, index)
|
|
||||||
}
|
|
||||||
if toolCall.Type == "" || toolCall.Type == "function" {
|
|
||||||
return dto.ResponsesOutput{
|
|
||||||
Type: responsesOutputTypeFunctionCall,
|
|
||||||
ID: callID,
|
|
||||||
Status: status,
|
|
||||||
CallId: callID,
|
|
||||||
Name: toolCall.Function.Name,
|
|
||||||
Arguments: chatArgumentsRawMessage(toolCall.Function.Arguments),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return dto.ResponsesOutput{
|
|
||||||
Type: toolCall.Type,
|
|
||||||
ID: callID,
|
|
||||||
Status: status,
|
|
||||||
CallId: callID,
|
|
||||||
Arguments: toolCall.Custom,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatArgumentsRawMessage(arguments string) []byte {
|
|
||||||
raw, err := common.Marshal(arguments)
|
|
||||||
if err != nil {
|
|
||||||
return []byte(`""`)
|
|
||||||
}
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
func chatCreatedAt(created any) int {
|
|
||||||
switch v := created.(type) {
|
|
||||||
case int:
|
|
||||||
return v
|
|
||||||
case int64:
|
|
||||||
return int(v)
|
|
||||||
case float64:
|
|
||||||
return int(v)
|
|
||||||
case float32:
|
|
||||||
return int(v)
|
|
||||||
case string:
|
|
||||||
if parsed := common.String2Int(v); parsed != 0 {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return int(time.Now().Unix())
|
|
||||||
}
|
|
||||||
|
|
||||||
func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
|
|
||||||
payload.Type = eventType
|
|
||||||
return ChatToResponsesStreamEvent{
|
|
||||||
Type: eventType,
|
|
||||||
Payload: payload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intPtr(v int) *int {
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package oairesponses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
|
||||||
|
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||||
|
responsesRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if responsesRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||||
|
}
|
||||||
|
return responsesRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
|
||||||
|
return openAIResponsesRequestFromAny(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesInputItems(raw []byte) ([]map[string]any, error) {
|
||||||
|
if !rawJSONPresent(raw) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch common.GetJsonType(raw) {
|
||||||
|
case "string":
|
||||||
|
input, err := responsesJSONString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||||
|
}
|
||||||
|
return []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": input,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
case "array":
|
||||||
|
var items []map[string]any
|
||||||
|
if err := common.Unmarshal(raw, &items); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InputItems(raw []byte) ([]map[string]any, error) {
|
||||||
|
return responsesInputItems(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesContentParts(content any) ([]map[string]any, error) {
|
||||||
|
switch typed := content.(type) {
|
||||||
|
case nil:
|
||||||
|
return nil, nil
|
||||||
|
case string:
|
||||||
|
return []map[string]any{{"type": "input_text", "text": typed}}, nil
|
||||||
|
case []map[string]any:
|
||||||
|
return typed, nil
|
||||||
|
case []any:
|
||||||
|
parts := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
switch part := item.(type) {
|
||||||
|
case string:
|
||||||
|
parts = append(parts, map[string]any{"type": "input_text", "text": part})
|
||||||
|
case map[string]any:
|
||||||
|
parts = append(parts, part)
|
||||||
|
default:
|
||||||
|
raw, err := common.Marshal(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts = append(parts, map[string]any{"type": "input_text", "text": string(raw)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
default:
|
||||||
|
raw, err := common.Marshal(typed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []map[string]any{{"type": "input_text", "text": string(raw)}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContentParts(content any) ([]map[string]any, error) {
|
||||||
|
return responsesContentParts(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesRequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
|
||||||
|
if !rawJSONPresent(raw) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var tools []map[string]any
|
||||||
|
if err := common.Unmarshal(raw, &tools); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
functions := make([]dto.FunctionRequest, 0, len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(common.Interface2String(tool["name"]))
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
functions = append(functions, dto.FunctionRequest{
|
||||||
|
Name: name,
|
||||||
|
Description: common.Interface2String(tool["description"]),
|
||||||
|
Parameters: tool["parameters"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return functions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
|
||||||
|
return responsesRequestFunctionDeclarations(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesReasoningEffort(req *dto.OpenAIResponsesRequest) string {
|
||||||
|
if req == nil || req.Reasoning == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return req.Reasoning.Effort
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReasoningEffort(req *dto.OpenAIResponsesRequest) string {
|
||||||
|
return responsesReasoningEffort(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesObjectValue(value any, fallbackKey string) map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return map[string]any{}
|
||||||
|
case map[string]any:
|
||||||
|
return typed
|
||||||
|
case string:
|
||||||
|
var object map[string]any
|
||||||
|
if err := common.Unmarshal([]byte(typed), &object); err == nil {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
var array []any
|
||||||
|
if err := common.Unmarshal([]byte(typed), &array); err == nil {
|
||||||
|
return map[string]any{fallbackKey: array}
|
||||||
|
}
|
||||||
|
return map[string]any{fallbackKey: typed}
|
||||||
|
case []any:
|
||||||
|
return map[string]any{fallbackKey: typed}
|
||||||
|
default:
|
||||||
|
return map[string]any{fallbackKey: typed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ObjectValue(value any, fallbackKey string) map[string]any {
|
||||||
|
return responsesObjectValue(value, fallbackKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesGeminiResponseMap(value any) map[string]interface{} {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return map[string]interface{}{}
|
||||||
|
case map[string]any:
|
||||||
|
return typed
|
||||||
|
case string:
|
||||||
|
var object map[string]interface{}
|
||||||
|
if err := common.Unmarshal([]byte(typed), &object); err == nil {
|
||||||
|
return object
|
||||||
|
}
|
||||||
|
var array []interface{}
|
||||||
|
if err := common.Unmarshal([]byte(typed), &array); err == nil {
|
||||||
|
return map[string]interface{}{"result": array}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"content": typed}
|
||||||
|
case []any:
|
||||||
|
return map[string]interface{}{"result": typed}
|
||||||
|
default:
|
||||||
|
return map[string]interface{}{"content": typed}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GeminiResponseMap(value any) map[string]interface{} {
|
||||||
|
return responsesGeminiResponseMap(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesParallelToolCalls(raw []byte) *bool {
|
||||||
|
if !rawJSONPresent(raw) || common.GetJsonType(raw) != "boolean" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var parallelToolCalls bool
|
||||||
|
if err := common.Unmarshal(raw, ¶llelToolCalls); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ¶llelToolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParallelToolCalls(raw []byte) *bool {
|
||||||
|
return responsesParallelToolCalls(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContentPartToFileSource(part map[string]any) types.FileSource {
|
||||||
|
partType := strings.TrimSpace(common.Interface2String(part["type"]))
|
||||||
|
var data string
|
||||||
|
var mimeType string
|
||||||
|
|
||||||
|
switch partType {
|
||||||
|
case "input_image":
|
||||||
|
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
|
||||||
|
case "input_file":
|
||||||
|
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
|
||||||
|
case "input_audio":
|
||||||
|
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
|
||||||
|
if mimeType == "" {
|
||||||
|
if payload, ok := part["input_audio"].(map[string]any); ok {
|
||||||
|
if format := strings.TrimSpace(common.Interface2String(payload["format"])); format != "" {
|
||||||
|
mimeType = "audio/" + format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "input_video":
|
||||||
|
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
|
||||||
|
}
|
||||||
|
if data == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return types.NewFileSourceFromData(data, mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
|
||||||
|
mimeType := strings.TrimSpace(common.Interface2String(part["mime_type"]))
|
||||||
|
for _, key := range keys {
|
||||||
|
value, ok := part[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
if typed != "" {
|
||||||
|
return typed, mimeType
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = strings.TrimSpace(common.Interface2String(typed["mime_type"]))
|
||||||
|
}
|
||||||
|
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
|
||||||
|
if data := strings.TrimSpace(common.Interface2String(typed[nestedKey])); data != "" {
|
||||||
|
return data, mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", mimeType
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package oairesponses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||||
|
sharedclaude "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/claude"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertOpenAIResponsesRequestToClaudeMessages(c *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return OpenAIResponsesRequestToClaudeMessages(c, responsesRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIResponsesRequestToClaudeMessages(c *gin.Context, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request is nil")
|
||||||
|
}
|
||||||
|
if req.Model == "" {
|
||||||
|
return nil, fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeRequest := &dto.ClaudeRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
TopP: req.TopP,
|
||||||
|
Stream: req.Stream,
|
||||||
|
}
|
||||||
|
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
|
||||||
|
claudeRequest.MaxTokens = common.GetPointer(*req.MaxOutputTokens)
|
||||||
|
}
|
||||||
|
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
|
||||||
|
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model))
|
||||||
|
claudeRequest.MaxTokens = &defaultMaxTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
functions, err := RequestFunctionDeclarations(req.Tools)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(functions) > 0 {
|
||||||
|
claudeRequest.Tools = responsesFunctionDeclarationsToClaudeTools(functions)
|
||||||
|
}
|
||||||
|
|
||||||
|
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
|
||||||
|
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
|
||||||
|
}
|
||||||
|
applyResponsesReasoningToClaude(req, claudeRequest)
|
||||||
|
|
||||||
|
systemMessages := make([]dto.ClaudeMediaMessage, 0)
|
||||||
|
if RawJSONPresent(req.Instructions) {
|
||||||
|
instructions, err := JSONString(req.Instructions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(instructions) != "" {
|
||||||
|
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer(instructions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputItems, err := InputItems(req.Input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range inputItems {
|
||||||
|
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||||
|
switch itemType {
|
||||||
|
case ResponsesInputTypeFunctionCall:
|
||||||
|
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
|
||||||
|
case ResponsesInputTypeCustomToolCall:
|
||||||
|
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
|
||||||
|
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||||
|
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
|
||||||
|
default:
|
||||||
|
role := responsesClaudeRole(item)
|
||||||
|
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if role == "system" {
|
||||||
|
systemMessages = append(systemMessages, parts...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
parts = []dto.ClaudeMediaMessage{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer("..."),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claudeRequest.Messages = append(claudeRequest.Messages, dto.ClaudeMessage{
|
||||||
|
Role: role,
|
||||||
|
Content: parts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(systemMessages) > 0 {
|
||||||
|
claudeRequest.System = systemMessages
|
||||||
|
}
|
||||||
|
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
|
||||||
|
return claudeRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest) []any {
|
||||||
|
tools := make([]any, 0, len(functions))
|
||||||
|
for _, function := range functions {
|
||||||
|
tools = append(tools, &dto.Tool{
|
||||||
|
Name: function.Name,
|
||||||
|
Description: function.Description,
|
||||||
|
InputSchema: responsesFunctionParametersToClaudeInputSchema(function.Parameters),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionParametersToClaudeInputSchema(parameters any) map[string]interface{} {
|
||||||
|
if params, ok := parameters.(map[string]any); ok {
|
||||||
|
schema := make(map[string]interface{}, len(params))
|
||||||
|
for key, value := range params {
|
||||||
|
schema[key] = value
|
||||||
|
}
|
||||||
|
if schema["type"] == nil {
|
||||||
|
schema["type"] = "object"
|
||||||
|
}
|
||||||
|
if schema["properties"] == nil {
|
||||||
|
schema["properties"] = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyResponsesReasoningToClaude(req *dto.OpenAIResponsesRequest, claudeRequest *dto.ClaudeRequest) {
|
||||||
|
effort := ReasoningEffort(req)
|
||||||
|
switch effort {
|
||||||
|
case "low":
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer(1280),
|
||||||
|
}
|
||||||
|
case "medium":
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer(2048),
|
||||||
|
}
|
||||||
|
case "high":
|
||||||
|
claudeRequest.Thinking = &dto.Thinking{
|
||||||
|
Type: "enabled",
|
||||||
|
BudgetTokens: common.GetPointer(4096),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesInputContentToClaudeMediaMessages(c *gin.Context, content any) ([]dto.ClaudeMediaMessage, error) {
|
||||||
|
contentParts, err := ContentParts(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]dto.ClaudeMediaMessage, 0, len(contentParts))
|
||||||
|
for _, contentPart := range contentParts {
|
||||||
|
partType := strings.TrimSpace(common.Interface2String(contentPart["type"]))
|
||||||
|
switch partType {
|
||||||
|
case "input_text", "output_text", "text":
|
||||||
|
text := common.Interface2String(contentPart["text"])
|
||||||
|
if text != "" {
|
||||||
|
parts = append(parts, dto.ClaudeMediaMessage{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer(text),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "input_image", "input_file", "input_audio", "input_video":
|
||||||
|
source := ContentPartToFileSource(contentPart)
|
||||||
|
if source == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||||
|
}
|
||||||
|
claudePart := dto.ClaudeMediaMessage{
|
||||||
|
Source: &dto.ClaudeMessageSource{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: mimeType,
|
||||||
|
Data: base64Data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||||
|
claudePart.Type = "document"
|
||||||
|
} else {
|
||||||
|
claudePart.Type = "image"
|
||||||
|
}
|
||||||
|
parts = append(parts, claudePart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
|
||||||
|
return dto.ClaudeMediaMessage{
|
||||||
|
Type: "tool_use",
|
||||||
|
Id: CallID(item),
|
||||||
|
Name: strings.TrimSpace(common.Interface2String(item["name"])),
|
||||||
|
Input: ObjectValue(item[inputKey], inputKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
|
||||||
|
return dto.ClaudeMediaMessage{
|
||||||
|
Type: "tool_result",
|
||||||
|
ToolUseId: CallID(item),
|
||||||
|
Content: responsesToolOutputValue(item["output"]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesToolOutputValue(value any) any {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||||
|
if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" {
|
||||||
|
last := messages[len(messages)-1]
|
||||||
|
parts := claudeMessageContentParts(last.Content)
|
||||||
|
parts = append(parts, toolUse)
|
||||||
|
last.Content = parts
|
||||||
|
messages[len(messages)-1] = last
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
return append(messages, dto.ClaudeMessage{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ClaudeMediaMessage{toolUse},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendClaudeToolResult(messages []dto.ClaudeMessage, toolResult dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||||
|
if len(messages) > 0 && messages[len(messages)-1].Role == "user" {
|
||||||
|
last := messages[len(messages)-1]
|
||||||
|
parts := claudeMessageContentParts(last.Content)
|
||||||
|
parts = append(parts, toolResult)
|
||||||
|
last.Content = parts
|
||||||
|
messages[len(messages)-1] = last
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
return append(messages, dto.ClaudeMessage{
|
||||||
|
Role: "user",
|
||||||
|
Content: []dto.ClaudeMediaMessage{toolResult},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
|
||||||
|
switch typed := content.(type) {
|
||||||
|
case []dto.ClaudeMediaMessage:
|
||||||
|
return typed
|
||||||
|
case string:
|
||||||
|
if typed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []dto.ClaudeMediaMessage{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer(typed),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
parts, _ := common.Any2Type[[]dto.ClaudeMediaMessage](content)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesClaudeRole(item map[string]any) string {
|
||||||
|
switch strings.TrimSpace(common.Interface2String(item["role"])) {
|
||||||
|
case "assistant":
|
||||||
|
return "assistant"
|
||||||
|
case "system", "developer":
|
||||||
|
return "system"
|
||||||
|
default:
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
|
||||||
|
if len(messages) == 0 || messages[0].Role == "user" {
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
return append([]dto.ClaudeMessage{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: []dto.ClaudeMediaMessage{
|
||||||
|
{
|
||||||
|
Type: "text",
|
||||||
|
Text: common.GetPointer("..."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, messages...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package oairesponses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||||
|
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||||
|
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertOpenAIResponsesRequestToGeminiChat(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIResponsesRequestToGeminiChat(c *gin.Context, req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||||
|
if req == nil {
|
||||||
|
return nil, fmt.Errorf("request is nil")
|
||||||
|
}
|
||||||
|
if req.Model == "" {
|
||||||
|
return nil, fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiRequest := &dto.GeminiChatRequest{
|
||||||
|
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||||
|
Temperature: req.Temperature,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if req.TopP != nil && *req.TopP > 0 {
|
||||||
|
geminiRequest.GenerationConfig.TopP = common.GetPointer(*req.TopP)
|
||||||
|
}
|
||||||
|
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
|
||||||
|
geminiRequest.GenerationConfig.MaxOutputTokens = common.GetPointer(*req.MaxOutputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamModelName := req.Model
|
||||||
|
if modelName := relaymeta.RelayInfoUpstreamModelName(info); modelName != "" {
|
||||||
|
upstreamModelName = modelName
|
||||||
|
}
|
||||||
|
if model_setting.IsGeminiModelSupportImagine(upstreamModelName) {
|
||||||
|
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
|
||||||
|
}
|
||||||
|
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sharedgemini.ApplyThinkingConfig(geminiRequest, info, dto.GeneralOpenAIRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
MaxCompletionTokens: req.MaxOutputTokens,
|
||||||
|
ReasoningEffort: ReasoningEffort(req),
|
||||||
|
})
|
||||||
|
|
||||||
|
safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(sharedgemini.SafetySettingCategories))
|
||||||
|
for _, category := range sharedgemini.SafetySettingCategories {
|
||||||
|
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
|
||||||
|
Category: category,
|
||||||
|
Threshold: model_setting.GetGeminiSafetySetting(category),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
geminiRequest.SafetySettings = safetySettings
|
||||||
|
|
||||||
|
functions, err := RequestFunctionDeclarations(req.Tools)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range functions {
|
||||||
|
if params, ok := functions[i].Parameters.(map[string]interface{}); ok {
|
||||||
|
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
|
||||||
|
functions[i].Parameters = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
functions[i].Parameters = sharedgemini.CleanFunctionParameters(functions[i].Parameters)
|
||||||
|
}
|
||||||
|
if len(functions) > 0 {
|
||||||
|
geminiRequest.SetTools([]dto.GeminiChatTool{
|
||||||
|
{FunctionDeclarations: functions},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if toolChoice != nil {
|
||||||
|
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(toolChoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemTexts := make([]string, 0)
|
||||||
|
if RawJSONPresent(req.Instructions) {
|
||||||
|
instructions, err := JSONString(req.Instructions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(instructions) != "" {
|
||||||
|
systemTexts = append(systemTexts, instructions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputItems, err := InputItems(req.Input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
callNames := make(map[string]string)
|
||||||
|
for _, item := range inputItems {
|
||||||
|
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||||
|
switch itemType {
|
||||||
|
case ResponsesInputTypeFunctionCall:
|
||||||
|
part, callID, err := responsesFunctionCallItemToGeminiPart(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sharedgemini.AttachFunctionCallThoughtSignature(&part)
|
||||||
|
if callID != "" {
|
||||||
|
callNames[callID] = part.FunctionCall.FunctionName
|
||||||
|
}
|
||||||
|
appendGeminiContentPart(geminiRequest, "model", part)
|
||||||
|
case ResponsesInputTypeFunctionCallOutput:
|
||||||
|
part := responsesFunctionOutputItemToGeminiPart(item, callNames)
|
||||||
|
appendGeminiContentPart(geminiRequest, "user", part)
|
||||||
|
default:
|
||||||
|
role := responsesGeminiRole(item)
|
||||||
|
parts, err := responsesInputContentToGeminiParts(c, item["content"])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if role == "system" {
|
||||||
|
for _, part := range parts {
|
||||||
|
if part.Text != "" {
|
||||||
|
systemTexts = append(systemTexts, part.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
|
||||||
|
Role: role,
|
||||||
|
Parts: parts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(systemTexts) > 0 {
|
||||||
|
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
|
||||||
|
Parts: []dto.GeminiPart{{Text: strings.Join(systemTexts, "\n")}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return geminiRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
|
||||||
|
responseFormat, err := RequestTextToChatResponseFormat(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if responseFormat == nil || (responseFormat.Type != "json_schema" && responseFormat.Type != "json_object") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
|
||||||
|
if len(responseFormat.JsonSchema) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var jsonSchema dto.FormatJsonSchema
|
||||||
|
if err := common.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
geminiRequest.GenerationConfig.ResponseSchema = sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesInputContentToGeminiParts(c *gin.Context, content any) ([]dto.GeminiPart, error) {
|
||||||
|
contentParts, err := ContentParts(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := make([]dto.GeminiPart, 0, len(contentParts))
|
||||||
|
for _, contentPart := range contentParts {
|
||||||
|
nextParts, err := responsesContentPartToGeminiParts(c, contentPart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
parts = append(parts, nextParts...)
|
||||||
|
}
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesContentPartToGeminiParts(c *gin.Context, part map[string]any) ([]dto.GeminiPart, error) {
|
||||||
|
partType := strings.TrimSpace(common.Interface2String(part["type"]))
|
||||||
|
switch partType {
|
||||||
|
case "input_text", "output_text", "text":
|
||||||
|
text := common.Interface2String(part["text"])
|
||||||
|
if text == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []dto.GeminiPart{{Text: text}}, nil
|
||||||
|
case "input_image", "input_file", "input_audio", "input_video":
|
||||||
|
source := ContentPartToFileSource(part)
|
||||||
|
if source == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Gemini")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||||
|
}
|
||||||
|
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||||
|
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
|
||||||
|
}
|
||||||
|
return []dto.GeminiPart{
|
||||||
|
{
|
||||||
|
InlineData: &dto.GeminiInlineData{
|
||||||
|
MimeType: mimeType,
|
||||||
|
Data: base64Data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
|
||||||
|
name := strings.TrimSpace(common.Interface2String(item["name"]))
|
||||||
|
if name == "" {
|
||||||
|
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
|
||||||
|
}
|
||||||
|
callID := CallID(item)
|
||||||
|
return dto.GeminiPart{
|
||||||
|
FunctionCall: &dto.FunctionCall{
|
||||||
|
FunctionName: name,
|
||||||
|
Arguments: ObjectValue(item["arguments"], "arguments"),
|
||||||
|
},
|
||||||
|
}, callID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) dto.GeminiPart {
|
||||||
|
callID := CallID(item)
|
||||||
|
name := strings.TrimSpace(common.Interface2String(item["name"]))
|
||||||
|
if name == "" {
|
||||||
|
name = callNames[callID]
|
||||||
|
}
|
||||||
|
return dto.GeminiPart{
|
||||||
|
FunctionResponse: &dto.GeminiFunctionResponse{
|
||||||
|
Name: name,
|
||||||
|
Response: GeminiResponseMap(item["output"]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
|
||||||
|
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
|
||||||
|
if role == "model" && part.FunctionCall != nil {
|
||||||
|
parts := req.Contents[len(req.Contents)-1].Parts
|
||||||
|
insertAt := 0
|
||||||
|
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
|
||||||
|
insertAt++
|
||||||
|
}
|
||||||
|
parts = append(parts, dto.GeminiPart{})
|
||||||
|
copy(parts[insertAt+1:], parts[insertAt:])
|
||||||
|
parts[insertAt] = part
|
||||||
|
req.Contents[len(req.Contents)-1].Parts = parts
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Contents = append(req.Contents, dto.GeminiChatContent{
|
||||||
|
Role: role,
|
||||||
|
Parts: []dto.GeminiPart{part},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func responsesGeminiRole(item map[string]any) string {
|
||||||
|
switch strings.TrimSpace(common.Interface2String(item["role"])) {
|
||||||
|
case "assistant":
|
||||||
|
return "model"
|
||||||
|
case "system", "developer":
|
||||||
|
return "system"
|
||||||
|
case "model":
|
||||||
|
return "model"
|
||||||
|
default:
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -1,4 +1,4 @@
|
|||||||
package gemini
|
package oairesponses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,7 +13,11 @@ const (
|
|||||||
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
|
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func preprocessGeminiOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
|
const (
|
||||||
|
ResponsesInputTypeCustomToolCallOutput = geminiResponsesInputTypeCustomToolCallOutput
|
||||||
|
)
|
||||||
|
|
||||||
|
func PrepareOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
|
||||||
tools, err := filterGeminiResponsesTools(request.Tools)
|
tools, err := filterGeminiResponsesTools(request.Tools)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return request, err
|
return request, err
|
||||||
@@ -42,7 +46,6 @@ func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
|
|||||||
filtered := make([]map[string]any, 0, len(tools))
|
filtered := make([]map[string]any, 0, len(tools))
|
||||||
for _, tool := range tools {
|
for _, tool := range tools {
|
||||||
if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
|
if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
|
||||||
// TODO: Support Responses custom/freeform tools when Gemini has a safe equivalent representation.
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filtered = append(filtered, tool)
|
filtered = append(filtered, tool)
|
||||||
@@ -78,7 +81,6 @@ func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
|
|||||||
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
|
||||||
switch itemType {
|
switch itemType {
|
||||||
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
|
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
|
||||||
// TODO: Support Responses custom/freeform tool calls once Gemini can preserve their semantics.
|
|
||||||
continue
|
continue
|
||||||
case geminiResponsesInputTypeFunctionCallOutput:
|
case geminiResponsesInputTypeFunctionCallOutput:
|
||||||
if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok {
|
if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok {
|
||||||
+33
-1
@@ -1,4 +1,4 @@
|
|||||||
package relayconvert
|
package oairesponses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -14,6 +14,14 @@ const (
|
|||||||
responsesInputTypeFunctionCall = "function_call"
|
responsesInputTypeFunctionCall = "function_call"
|
||||||
responsesInputTypeFunctionCallOutput = "function_call_output"
|
responsesInputTypeFunctionCallOutput = "function_call_output"
|
||||||
responsesInputTypeCustomToolCall = "custom_tool_call"
|
responsesInputTypeCustomToolCall = "custom_tool_call"
|
||||||
|
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
|
||||||
|
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
|
||||||
|
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
|
||||||
|
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
|
||||||
)
|
)
|
||||||
|
|
||||||
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||||
@@ -109,6 +117,10 @@ func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesReque
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||||
|
return validateResponsesRequestChatUnsupportedFields(req)
|
||||||
|
}
|
||||||
|
|
||||||
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||||
messages := make([]dto.Message, 0)
|
messages := make([]dto.Message, 0)
|
||||||
if rawJSONPresent(req.Instructions) {
|
if rawJSONPresent(req.Instructions) {
|
||||||
@@ -373,6 +385,10 @@ func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
|||||||
return choice, nil
|
return choice, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||||
|
return responsesRequestToolChoiceToChat(raw)
|
||||||
|
}
|
||||||
|
|
||||||
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||||
if !rawJSONPresent(raw) {
|
if !rawJSONPresent(raw) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -403,6 +419,10 @@ func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.Respons
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||||
|
return responsesRequestTextToChatResponseFormat(raw)
|
||||||
|
}
|
||||||
|
|
||||||
func responsesImagePartToChatImageURL(part map[string]any) any {
|
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||||
if imageURL, ok := part["image_url"]; ok {
|
if imageURL, ok := part["image_url"]; ok {
|
||||||
return imageURL
|
return imageURL
|
||||||
@@ -472,6 +492,10 @@ func responsesCallID(item map[string]any) string {
|
|||||||
return strings.TrimSpace(common.Interface2String(item["id"]))
|
return strings.TrimSpace(common.Interface2String(item["id"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CallID(item map[string]any) string {
|
||||||
|
return responsesCallID(item)
|
||||||
|
}
|
||||||
|
|
||||||
func responsesArgumentsString(value any) string {
|
func responsesArgumentsString(value any) string {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
@@ -519,3 +543,11 @@ func rawJSONPresent(raw json.RawMessage) bool {
|
|||||||
}
|
}
|
||||||
return common.GetJsonType(raw) != "null"
|
return common.GetJsonType(raw) != "null"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func JSONString(raw json.RawMessage) (string, error) {
|
||||||
|
return responsesJSONString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RawJSONPresent(raw json.RawMessage) bool {
|
||||||
|
return rawJSONPresent(raw)
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package relayconvert
|
package oairesponses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package oairesponses
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
responsesEventCreated = "response.created"
|
||||||
|
responsesEventCompleted = "response.completed"
|
||||||
|
responsesEventDone = "response.done"
|
||||||
|
responsesEventIncomplete = "response.incomplete"
|
||||||
|
responsesEventFailed = "response.failed"
|
||||||
|
responsesEventError = "response.error"
|
||||||
|
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||||
|
responsesEventOutputItemAdded = "response.output_item.added"
|
||||||
|
responsesEventOutputItemDone = "response.output_item.done"
|
||||||
|
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||||
|
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||||
|
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
||||||
|
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
||||||
|
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||||
|
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||||
|
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
|
||||||
|
responsesEventReasoningTextDone = "response.reasoning_text.done"
|
||||||
|
responsesOutputTypeFunctionCall = "function_call"
|
||||||
|
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||||
|
responsesOutputTypeMessage = "message"
|
||||||
|
responsesOutputTypeReasoning = "reasoning"
|
||||||
|
responsesIncompleteReasonContentFilter = "content_filter"
|
||||||
|
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||||
|
if resp == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
status := responseStatusString(resp)
|
||||||
|
if status != "incomplete" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
reason := ""
|
||||||
|
if resp.IncompleteDetails != nil {
|
||||||
|
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
|
||||||
|
}
|
||||||
|
if reason == responsesIncompleteReasonContentFilter {
|
||||||
|
return "content_filter", true
|
||||||
|
}
|
||||||
|
return "length", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||||
|
if resp == nil {
|
||||||
|
return nil, nil, errors.New("response is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
text := ExtractOutputTextFromResponses(resp)
|
||||||
|
reasoning := ExtractReasoningTextFromResponses(resp)
|
||||||
|
|
||||||
|
usage := UsageFromResponsesUsage(resp.Usage)
|
||||||
|
|
||||||
|
created := resp.CreatedAt
|
||||||
|
|
||||||
|
var toolCalls []dto.ToolCallResponse
|
||||||
|
if len(resp.Output) > 0 {
|
||||||
|
for _, out := range resp.Output {
|
||||||
|
if !isResponsesToolOutputType(out.Type) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(out.Name)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
callId := strings.TrimSpace(out.CallId)
|
||||||
|
if callId == "" {
|
||||||
|
callId = strings.TrimSpace(out.ID)
|
||||||
|
}
|
||||||
|
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||||
|
ID: callId,
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionResponse{
|
||||||
|
Name: name,
|
||||||
|
Arguments: out.ArgumentsString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finishReason := "stop"
|
||||||
|
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
||||||
|
finishReason = mappedReason
|
||||||
|
} else if len(toolCalls) > 0 {
|
||||||
|
finishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := dto.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: text,
|
||||||
|
}
|
||||||
|
if reasoning != "" {
|
||||||
|
msg.ReasoningContent = &reasoning
|
||||||
|
}
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
msg.SetToolCalls(toolCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &dto.OpenAITextResponse{
|
||||||
|
Id: id,
|
||||||
|
Object: "chat.completion",
|
||||||
|
Created: created,
|
||||||
|
Model: resp.Model,
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{
|
||||||
|
Index: 0,
|
||||||
|
Message: msg,
|
||||||
|
FinishReason: finishReason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: *usage,
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, usage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||||
|
usage := &dto.Usage{}
|
||||||
|
if src == nil {
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
usage.UsageSemantic = src.UsageSemantic
|
||||||
|
usage.UsageSource = src.UsageSource
|
||||||
|
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
|
||||||
|
if usage.BillingUsage == nil {
|
||||||
|
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
|
||||||
|
}
|
||||||
|
usage.Cost = src.Cost
|
||||||
|
if src.InputTokens != 0 {
|
||||||
|
usage.PromptTokens = src.InputTokens
|
||||||
|
usage.InputTokens = src.InputTokens
|
||||||
|
}
|
||||||
|
if src.OutputTokens != 0 {
|
||||||
|
usage.CompletionTokens = src.OutputTokens
|
||||||
|
usage.OutputTokens = src.OutputTokens
|
||||||
|
}
|
||||||
|
if src.TotalTokens != 0 {
|
||||||
|
usage.TotalTokens = src.TotalTokens
|
||||||
|
} else {
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
}
|
||||||
|
if src.InputTokensDetails != nil {
|
||||||
|
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||||
|
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
|
||||||
|
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
|
||||||
|
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||||
|
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||||
|
}
|
||||||
|
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.TextTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.AudioTokens != 0 ||
|
||||||
|
src.CompletionTokenDetails.ImageTokens != 0 {
|
||||||
|
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
|
||||||
|
usage.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens
|
||||||
|
usage.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens
|
||||||
|
usage.CompletionTokenDetails.ImageTokens = src.CompletionTokenDetails.ImageTokens
|
||||||
|
}
|
||||||
|
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
|
||||||
|
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
if resp == nil || len(resp.Output) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Prefer assistant message outputs.
|
||||||
|
for _, out := range resp.Output {
|
||||||
|
if out.Type != "message" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.Role != "" && out.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range out.Content {
|
||||||
|
if c.Type == "output_text" && c.Text != "" {
|
||||||
|
sb.WriteString(c.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sb.Len() > 0 {
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
for _, out := range resp.Output {
|
||||||
|
for _, c := range out.Content {
|
||||||
|
if c.Text != "" {
|
||||||
|
sb.WriteString(c.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
if resp == nil || len(resp.Output) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, out := range resp.Output {
|
||||||
|
if out.Type != responsesOutputTypeReasoning {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range out.Content {
|
||||||
|
if c.Text != "" {
|
||||||
|
sb.WriteString(c.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
if resp == nil || len(resp.Status) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
_ = common.Unmarshal(resp.Status, &status)
|
||||||
|
return strings.TrimSpace(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
|
||||||
|
if resp == nil {
|
||||||
|
resp = &dto.OpenAIResponsesResponse{}
|
||||||
|
}
|
||||||
|
if len(resp.Status) == 0 {
|
||||||
|
resp.Status = []byte(`"incomplete"`)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func isResponsesToolOutputType(outputType string) bool {
|
||||||
|
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
|
||||||
|
if event == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if event.Item != nil {
|
||||||
|
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||||
|
return itemID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(event.ItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
||||||
|
if outputIndex != nil {
|
||||||
|
return fmt.Sprintf("output:%d", *outputIndex)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(itemID) != "" {
|
||||||
|
return "item:" + strings.TrimSpace(itemID)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(callID) != "" {
|
||||||
|
return "call:" + strings.TrimSpace(callID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
|
||||||
|
if event == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(event.ItemID) != "" {
|
||||||
|
return strings.TrimSpace(event.ItemID)
|
||||||
|
}
|
||||||
|
if event.OutputIndex != nil {
|
||||||
|
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
+1
-184
@@ -1,51 +1,13 @@
|
|||||||
package relayconvert
|
package oairesponses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
"github.com/samber/lo"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChatCompletionsRequestToResponsesRequestInstructionsAndTools(t *testing.T) {
|
|
||||||
req := &dto.GeneralOpenAIRequest{
|
|
||||||
Model: "gpt-test",
|
|
||||||
N: lo.ToPtr(1),
|
|
||||||
Messages: []dto.Message{
|
|
||||||
{Role: "system", Content: "system rules"},
|
|
||||||
{Role: "developer", Content: "developer rules"},
|
|
||||||
{Role: "user", Content: []any{
|
|
||||||
map[string]any{"type": "text", "text": "look"},
|
|
||||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.test/a.png"}},
|
|
||||||
}},
|
|
||||||
assistantMessageWithTool("partial text", "call_1", "lookup", `{"q":"x"}`),
|
|
||||||
{Role: "tool", ToolCallId: "call_1", Content: "tool result"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := ChatCompletionsRequestToResponsesRequest(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "gpt-test", got.Model)
|
|
||||||
assert.Equal(t, `"system rules\n\ndeveloper rules"`, string(got.Instructions))
|
|
||||||
assert.Equal(t, "input_image", gjson.GetBytes(got.Input, "0.content.1.type").String())
|
|
||||||
assert.Equal(t, "function_call", gjson.GetBytes(got.Input, "2.type").String())
|
|
||||||
assert.Equal(t, "call_1", gjson.GetBytes(got.Input, "2.call_id").String())
|
|
||||||
assert.Equal(t, "function_call_output", gjson.GetBytes(got.Input, "3.type").String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChatCompletionsRequestToResponsesRequestRejectsMultipleChoices(t *testing.T) {
|
|
||||||
_, err := ChatCompletionsRequestToResponsesRequest(&dto.GeneralOpenAIRequest{
|
|
||||||
Model: "gpt-test",
|
|
||||||
N: lo.ToPtr(2),
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "n>1")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
|
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
|
||||||
resp := &dto.OpenAIResponsesResponse{
|
resp := &dto.OpenAIResponsesResponse{
|
||||||
ID: "resp_1",
|
ID: "resp_1",
|
||||||
@@ -413,144 +375,6 @@ func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexA
|
|||||||
assert.Empty(t, acc.pendingByItemID)
|
assert.Empty(t, acc.pendingByItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *testing.T) {
|
|
||||||
chat := &dto.OpenAITextResponse{
|
|
||||||
Id: "chatcmpl_1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
Created: 456,
|
|
||||||
Choices: []dto.OpenAITextResponseChoice{
|
|
||||||
{
|
|
||||||
Message: assistantMessageWithTool("I will call.", "call_1", "lookup", `{"q":"x"}`),
|
|
||||||
FinishReason: "tool_calls",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Usage: dto.Usage{PromptTokens: 3, CompletionTokens: 5, TotalTokens: 8},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, usage, err := ChatCompletionsResponseToResponsesResponse(chat, "resp_1")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, usage)
|
|
||||||
|
|
||||||
assert.Equal(t, "resp_1", resp.ID)
|
|
||||||
assert.Equal(t, "response", resp.Object)
|
|
||||||
assert.Equal(t, `"completed"`, string(resp.Status))
|
|
||||||
assert.Equal(t, 3, resp.Usage.InputTokens)
|
|
||||||
assert.Equal(t, 5, resp.Usage.OutputTokens)
|
|
||||||
require.Len(t, resp.Output, 2)
|
|
||||||
assert.Equal(t, responsesOutputTypeMessage, resp.Output[0].Type)
|
|
||||||
assert.Equal(t, "I will call.", resp.Output[0].Content[0].Text)
|
|
||||||
assert.Equal(t, responsesOutputTypeFunctionCall, resp.Output[1].Type)
|
|
||||||
assert.Equal(t, "call_1", resp.Output[1].CallId)
|
|
||||||
assert.Equal(t, "lookup", resp.Output[1].Name)
|
|
||||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
finishReason string
|
|
||||||
wantReason string
|
|
||||||
}{
|
|
||||||
{name: "length", finishReason: "length", wantReason: responsesIncompleteReasonMaxTokens},
|
|
||||||
{name: "content filter", finishReason: "content_filter", wantReason: responsesIncompleteReasonContentFilter},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
|
|
||||||
Id: "chatcmpl_1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
Choices: []dto.OpenAITextResponseChoice{
|
|
||||||
{
|
|
||||||
Message: dto.Message{Role: "assistant", Content: "partial"},
|
|
||||||
FinishReason: tt.finishReason,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, "resp_1")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, `"incomplete"`, string(resp.Status))
|
|
||||||
require.NotNil(t, resp.IncompleteDetails)
|
|
||||||
assert.Equal(t, tt.wantReason, resp.IncompleteDetails.Reason)
|
|
||||||
require.Len(t, resp.Output, 1)
|
|
||||||
assert.Equal(t, "incomplete", resp.Output[0].Status)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChatCompletionsStreamToResponsesEventsAggregatesUsageAndToolArgs(t *testing.T) {
|
|
||||||
state := NewChatToResponsesStreamState("resp_1", "gpt-test")
|
|
||||||
state.Created = 123
|
|
||||||
toolIndex := 0
|
|
||||||
|
|
||||||
var events []ChatToResponsesStreamEvent
|
|
||||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
||||||
Id: "chatcmpl_1",
|
|
||||||
Model: "gpt-test",
|
|
||||||
Created: 123,
|
|
||||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
||||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Role: "assistant"}},
|
|
||||||
},
|
|
||||||
})...)
|
|
||||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
||||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
||||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: lo.ToPtr("hello")}},
|
|
||||||
},
|
|
||||||
})...)
|
|
||||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
||||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
||||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
|
||||||
{Index: &toolIndex, ID: "call_1", Type: "function", Function: dto.FunctionResponse{Name: "lookup"}},
|
|
||||||
}}},
|
|
||||||
},
|
|
||||||
})...)
|
|
||||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
||||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
||||||
{Index: 0, Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ToolCalls: []dto.ToolCallResponse{
|
|
||||||
{Index: &toolIndex, Function: dto.FunctionResponse{Arguments: `{"q":"x"}`}},
|
|
||||||
}}},
|
|
||||||
},
|
|
||||||
})...)
|
|
||||||
finishReason := "tool_calls"
|
|
||||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
||||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
|
||||||
{Index: 0, FinishReason: &finishReason},
|
|
||||||
},
|
|
||||||
})...)
|
|
||||||
events = append(events, mustResponsesEventsFromChatChunk(t, state, &dto.ChatCompletionsStreamResponse{
|
|
||||||
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 4, TotalTokens: 6},
|
|
||||||
})...)
|
|
||||||
events = append(events, FinalizeChatCompletionsStreamToResponses(state)...)
|
|
||||||
|
|
||||||
require.Len(t, events, 10)
|
|
||||||
assert.Equal(t, responsesEventCreated, events[0].Type)
|
|
||||||
assert.Equal(t, responsesEventOutputTextDelta, events[2].Type)
|
|
||||||
assert.Equal(t, "hello", events[2].Payload.Delta)
|
|
||||||
assert.Equal(t, responsesEventFunctionArgsDelta, events[4].Type)
|
|
||||||
assert.Equal(t, `{"q":"x"}`, events[4].Payload.Delta)
|
|
||||||
assert.Equal(t, responsesEventCompleted, events[9].Type)
|
|
||||||
require.NotNil(t, events[9].Payload.Response)
|
|
||||||
assert.Equal(t, 6, events[9].Payload.Response.Usage.TotalTokens)
|
|
||||||
require.Len(t, events[9].Payload.Response.Output, 2)
|
|
||||||
assert.Equal(t, "hello", events[9].Payload.Response.Output[0].Content[0].Text)
|
|
||||||
assert.Equal(t, `"{\"q\":\"x\"}"`, string(events[9].Payload.Response.Output[1].Arguments))
|
|
||||||
}
|
|
||||||
|
|
||||||
func assistantMessageWithTool(content string, id string, name string, args string) dto.Message {
|
|
||||||
msg := dto.Message{Role: "assistant", Content: content}
|
|
||||||
msg.SetToolCalls([]dto.ToolCallRequest{
|
|
||||||
{
|
|
||||||
ID: id,
|
|
||||||
Type: "function",
|
|
||||||
Function: dto.FunctionRequest{
|
|
||||||
Name: name,
|
|
||||||
Arguments: args,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestResponsesStreamState() *ResponsesToChatStreamState {
|
func newTestResponsesStreamState() *ResponsesToChatStreamState {
|
||||||
state := NewResponsesToChatStreamState("gpt-test", false)
|
state := NewResponsesToChatStreamState("gpt-test", false)
|
||||||
state.ID = "chatcmpl_test"
|
state.ID = "chatcmpl_test"
|
||||||
@@ -564,10 +388,3 @@ func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dt
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return chunks
|
return chunks
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustResponsesEventsFromChatChunk(t *testing.T, state *ChatToResponsesStreamState, chunk *dto.ChatCompletionsStreamResponse) []ChatToResponsesStreamEvent {
|
|
||||||
t.Helper()
|
|
||||||
events, err := ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
+1
-264
@@ -1,7 +1,6 @@
|
|||||||
package relayconvert
|
package oairesponses
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -11,207 +10,6 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
responsesEventCreated = "response.created"
|
|
||||||
responsesEventCompleted = "response.completed"
|
|
||||||
responsesEventDone = "response.done"
|
|
||||||
responsesEventIncomplete = "response.incomplete"
|
|
||||||
responsesEventFailed = "response.failed"
|
|
||||||
responsesEventError = "response.error"
|
|
||||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
|
||||||
responsesEventOutputItemAdded = "response.output_item.added"
|
|
||||||
responsesEventOutputItemDone = "response.output_item.done"
|
|
||||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
|
||||||
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
|
||||||
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
|
||||||
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
|
||||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
|
||||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
|
||||||
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
|
|
||||||
responsesEventReasoningTextDone = "response.reasoning_text.done"
|
|
||||||
responsesOutputTypeFunctionCall = "function_call"
|
|
||||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
|
||||||
responsesOutputTypeMessage = "message"
|
|
||||||
responsesOutputTypeReasoning = "reasoning"
|
|
||||||
responsesIncompleteReasonContentFilter = "content_filter"
|
|
||||||
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
|
||||||
if resp == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
status := responseStatusString(resp)
|
|
||||||
if status != "incomplete" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
reason := ""
|
|
||||||
if resp.IncompleteDetails != nil {
|
|
||||||
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
|
|
||||||
}
|
|
||||||
if reason == responsesIncompleteReasonContentFilter {
|
|
||||||
return "content_filter", true
|
|
||||||
}
|
|
||||||
return "length", true
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
|
||||||
if resp == nil {
|
|
||||||
return nil, nil, errors.New("response is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
text := ExtractOutputTextFromResponses(resp)
|
|
||||||
reasoning := ExtractReasoningTextFromResponses(resp)
|
|
||||||
|
|
||||||
usage := UsageFromResponsesUsage(resp.Usage)
|
|
||||||
|
|
||||||
created := resp.CreatedAt
|
|
||||||
|
|
||||||
var toolCalls []dto.ToolCallResponse
|
|
||||||
if len(resp.Output) > 0 {
|
|
||||||
for _, out := range resp.Output {
|
|
||||||
if !isResponsesToolOutputType(out.Type) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.TrimSpace(out.Name)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
callId := strings.TrimSpace(out.CallId)
|
|
||||||
if callId == "" {
|
|
||||||
callId = strings.TrimSpace(out.ID)
|
|
||||||
}
|
|
||||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
|
||||||
ID: callId,
|
|
||||||
Type: "function",
|
|
||||||
Function: dto.FunctionResponse{
|
|
||||||
Name: name,
|
|
||||||
Arguments: out.ArgumentsString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
finishReason := "stop"
|
|
||||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
|
||||||
finishReason = mappedReason
|
|
||||||
} else if len(toolCalls) > 0 {
|
|
||||||
finishReason = "tool_calls"
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := dto.Message{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: text,
|
|
||||||
}
|
|
||||||
if reasoning != "" {
|
|
||||||
msg.ReasoningContent = &reasoning
|
|
||||||
}
|
|
||||||
if len(toolCalls) > 0 {
|
|
||||||
msg.SetToolCalls(toolCalls)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := &dto.OpenAITextResponse{
|
|
||||||
Id: id,
|
|
||||||
Object: "chat.completion",
|
|
||||||
Created: created,
|
|
||||||
Model: resp.Model,
|
|
||||||
Choices: []dto.OpenAITextResponseChoice{
|
|
||||||
{
|
|
||||||
Index: 0,
|
|
||||||
Message: msg,
|
|
||||||
FinishReason: finishReason,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Usage: *usage,
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
|
||||||
usage := &dto.Usage{}
|
|
||||||
if src == nil {
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
if src.InputTokens != 0 {
|
|
||||||
usage.PromptTokens = src.InputTokens
|
|
||||||
usage.InputTokens = src.InputTokens
|
|
||||||
}
|
|
||||||
if src.OutputTokens != 0 {
|
|
||||||
usage.CompletionTokens = src.OutputTokens
|
|
||||||
usage.OutputTokens = src.OutputTokens
|
|
||||||
}
|
|
||||||
if src.TotalTokens != 0 {
|
|
||||||
usage.TotalTokens = src.TotalTokens
|
|
||||||
} else {
|
|
||||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
|
||||||
}
|
|
||||||
if src.InputTokensDetails != nil {
|
|
||||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
|
||||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
|
||||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
|
||||||
}
|
|
||||||
if src.CompletionTokenDetails.ReasoningTokens != 0 {
|
|
||||||
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
|
|
||||||
}
|
|
||||||
return usage
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
|
||||||
if resp == nil || len(resp.Output) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
|
|
||||||
// Prefer assistant message outputs.
|
|
||||||
for _, out := range resp.Output {
|
|
||||||
if out.Type != "message" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if out.Role != "" && out.Role != "assistant" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, c := range out.Content {
|
|
||||||
if c.Type == "output_text" && c.Text != "" {
|
|
||||||
sb.WriteString(c.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if sb.Len() > 0 {
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
for _, out := range resp.Output {
|
|
||||||
for _, c := range out.Content {
|
|
||||||
if c.Text != "" {
|
|
||||||
sb.WriteString(c.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
|
||||||
if resp == nil || len(resp.Output) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, out := range resp.Output {
|
|
||||||
if out.Type != responsesOutputTypeReasoning {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, c := range out.Content {
|
|
||||||
if c.Text != "" {
|
|
||||||
sb.WriteString(c.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResponsesToChatStreamState struct {
|
type ResponsesToChatStreamState struct {
|
||||||
ID string
|
ID string
|
||||||
Model string
|
Model string
|
||||||
@@ -902,64 +700,3 @@ func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamR
|
|||||||
}
|
}
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
|
||||||
if resp == nil || len(resp.Status) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var status string
|
|
||||||
_ = common.Unmarshal(resp.Status, &status)
|
|
||||||
return strings.TrimSpace(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
|
|
||||||
if resp == nil {
|
|
||||||
resp = &dto.OpenAIResponsesResponse{}
|
|
||||||
}
|
|
||||||
if len(resp.Status) == 0 {
|
|
||||||
resp.Status = []byte(`"incomplete"`)
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
func isResponsesToolOutputType(outputType string) bool {
|
|
||||||
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
|
||||||
}
|
|
||||||
|
|
||||||
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
|
|
||||||
if event == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if event.Item != nil {
|
|
||||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
|
||||||
return itemID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(event.ItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
|
||||||
if outputIndex != nil {
|
|
||||||
return fmt.Sprintf("output:%d", *outputIndex)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(itemID) != "" {
|
|
||||||
return "item:" + strings.TrimSpace(itemID)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(callID) != "" {
|
|
||||||
return "call:" + strings.TrimSpace(callID)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
|
|
||||||
if event == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(event.ItemID) != "" {
|
|
||||||
return strings.TrimSpace(event.ItemID)
|
|
||||||
}
|
|
||||||
if event.OutputIndex != nil {
|
|
||||||
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||||
|
remainder := totalTokens - tokens5m - tokens1h
|
||||||
|
if remainder < 0 {
|
||||||
|
remainder = 0
|
||||||
|
}
|
||||||
|
return tokens5m + remainder, tokens1h
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package claude
|
||||||
|
|
||||||
|
import "github.com/QuantumNous/new-api/dto"
|
||||||
|
|
||||||
|
func MapOpenAIToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice {
|
||||||
|
var claudeToolChoice *dto.ClaudeToolChoice
|
||||||
|
|
||||||
|
if toolChoiceStr, ok := toolChoice.(string); ok {
|
||||||
|
switch toolChoiceStr {
|
||||||
|
case "auto":
|
||||||
|
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||||
|
Type: "auto",
|
||||||
|
}
|
||||||
|
case "required":
|
||||||
|
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||||
|
Type: "any",
|
||||||
|
}
|
||||||
|
case "none":
|
||||||
|
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||||
|
Type: "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
|
||||||
|
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
|
||||||
|
if toolName, ok := function["name"].(string); ok {
|
||||||
|
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||||
|
Type: "tool",
|
||||||
|
Name: toolName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parallelToolCalls != nil {
|
||||||
|
if claudeToolChoice == nil {
|
||||||
|
claudeToolChoice = &dto.ClaudeToolChoice{
|
||||||
|
Type: "auto",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claudeToolChoice.Type != "none" {
|
||||||
|
claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return claudeToolChoice
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
relaymeta "github.com/QuantumNous/new-api/service/relayconvert/internal/meta"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/QuantumNous/new-api/setting/reasoning"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SupportedMimeTypes = map[string]bool{
|
||||||
|
"application/pdf": true,
|
||||||
|
"audio/mpeg": true,
|
||||||
|
"audio/mp3": true,
|
||||||
|
"audio/wav": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/jpg": true,
|
||||||
|
"image/webp": true,
|
||||||
|
"image/heic": true,
|
||||||
|
"image/heif": true,
|
||||||
|
"text/plain": true,
|
||||||
|
"video/mov": true,
|
||||||
|
"video/mpeg": true,
|
||||||
|
"video/mp4": true,
|
||||||
|
"video/mpg": true,
|
||||||
|
"video/avi": true,
|
||||||
|
"video/wmv": true,
|
||||||
|
"video/mpegps": true,
|
||||||
|
"video/flv": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var SafetySettingCategories = []string{
|
||||||
|
"HARM_CATEGORY_HARASSMENT",
|
||||||
|
"HARM_CATEGORY_HATE_SPEECH",
|
||||||
|
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||||
|
"HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
|
||||||
|
|
||||||
|
const (
|
||||||
|
pro25MinBudget = 128
|
||||||
|
pro25MaxBudget = 32768
|
||||||
|
flash25MaxBudget = 24576
|
||||||
|
flash25LiteMinBudget = 512
|
||||||
|
flash25LiteMaxBudget = 24576
|
||||||
|
)
|
||||||
|
|
||||||
|
func ShouldAttachThoughtSignature() bool {
|
||||||
|
return model_setting.GetGeminiSettings().FunctionCallThoughtSignatureEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func AttachThoughtSignatureBypass(part *dto.GeminiPart) bool {
|
||||||
|
if part == nil || len(part.ThoughtSignature) > 0 || !ShouldAttachThoughtSignature() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
part.ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func AttachFunctionCallThoughtSignature(part *dto.GeminiPart) bool {
|
||||||
|
if part == nil || !HasFunctionCallContent(part.FunctionCall) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return AttachThoughtSignatureBypass(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AttachFirstTextThoughtSignature(parts []dto.GeminiPart) bool {
|
||||||
|
if !ShouldAttachThoughtSignature() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range parts {
|
||||||
|
if parts[i].Text != "" && len(parts[i].ThoughtSignature) == 0 {
|
||||||
|
parts[i].ThoughtSignature = []byte(strconv.Quote(ThoughtSignatureBypassValue))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
|
||||||
|
if geminiRequest == nil || info == nil || !model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName := relaymeta.RelayInfoUpstreamModelName(info)
|
||||||
|
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
|
||||||
|
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
|
||||||
|
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
|
||||||
|
|
||||||
|
if strings.Contains(modelName, "-thinking-") {
|
||||||
|
parts := strings.SplitN(modelName, "-thinking-", 2)
|
||||||
|
if len(parts) == 2 && parts[1] != "" {
|
||||||
|
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
|
||||||
|
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||||
|
ThinkingBudget: common.GetPointer(clampedBudget),
|
||||||
|
IncludeThoughts: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(modelName, "-thinking") {
|
||||||
|
unsupportedModels := []string{
|
||||||
|
"gemini-2.5-pro-preview-05-06",
|
||||||
|
"gemini-2.5-pro-preview-03-25",
|
||||||
|
}
|
||||||
|
isUnsupported := false
|
||||||
|
for _, unsupportedModel := range unsupportedModels {
|
||||||
|
if strings.HasPrefix(modelName, unsupportedModel) {
|
||||||
|
isUnsupported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isUnsupported {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||||
|
IncludeThoughts: true,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||||
|
IncludeThoughts: true,
|
||||||
|
}
|
||||||
|
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
|
||||||
|
budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(*geminiRequest.GenerationConfig.MaxOutputTokens)
|
||||||
|
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget)
|
||||||
|
} else if len(oaiRequest) > 0 {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(modelName, "-nothinking") {
|
||||||
|
if !isNew25Pro {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||||
|
ThinkingBudget: common.GetPointer(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if _, level, ok := reasoning.TrimEffortSuffix(modelName); ok && level != "" {
|
||||||
|
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
|
||||||
|
IncludeThoughts: true,
|
||||||
|
ThinkingLevel: level,
|
||||||
|
}
|
||||||
|
info.ReasoningEffort = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseStopSequences(stop any) []string {
|
||||||
|
if stop == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := stop.(type) {
|
||||||
|
case string:
|
||||||
|
if v != "" {
|
||||||
|
return []string{v}
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []interface{}:
|
||||||
|
sequences := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if str, ok := item.(string); ok && str != "" {
|
||||||
|
sequences = append(sequences, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sequences
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HasFunctionCallContent(call *dto.FunctionCall) bool {
|
||||||
|
if call == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(call.FunctionName) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := call.Arguments.(type) {
|
||||||
|
case nil:
|
||||||
|
return false
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v) != ""
|
||||||
|
case map[string]interface{}:
|
||||||
|
return len(v) > 0
|
||||||
|
case []interface{}:
|
||||||
|
return len(v) > 0
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SupportedMimeTypesList() []string {
|
||||||
|
keys := make([]string, 0, len(SupportedMimeTypes))
|
||||||
|
for key := range SupportedMimeTypes {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNew25ProModel(modelName string) bool {
|
||||||
|
return strings.HasPrefix(modelName, "gemini-2.5-pro") &&
|
||||||
|
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
|
||||||
|
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
|
||||||
|
}
|
||||||
|
|
||||||
|
func is25FlashLiteModel(modelName string) bool {
|
||||||
|
return strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampThinkingBudget(modelName string, budget int) int {
|
||||||
|
isNew25Pro := isNew25ProModel(modelName)
|
||||||
|
is25FlashLite := is25FlashLiteModel(modelName)
|
||||||
|
|
||||||
|
if is25FlashLite {
|
||||||
|
if budget < flash25LiteMinBudget {
|
||||||
|
return flash25LiteMinBudget
|
||||||
|
}
|
||||||
|
if budget > flash25LiteMaxBudget {
|
||||||
|
return flash25LiteMaxBudget
|
||||||
|
}
|
||||||
|
} else if isNew25Pro {
|
||||||
|
if budget < pro25MinBudget {
|
||||||
|
return pro25MinBudget
|
||||||
|
}
|
||||||
|
if budget > pro25MaxBudget {
|
||||||
|
return pro25MaxBudget
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if budget < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if budget > flash25MaxBudget {
|
||||||
|
return flash25MaxBudget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return budget
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampThinkingBudgetByEffort(modelName string, effort string) int {
|
||||||
|
isNew25Pro := isNew25ProModel(modelName)
|
||||||
|
is25FlashLite := is25FlashLiteModel(modelName)
|
||||||
|
|
||||||
|
maxBudget := 0
|
||||||
|
if is25FlashLite {
|
||||||
|
maxBudget = flash25LiteMaxBudget
|
||||||
|
}
|
||||||
|
if isNew25Pro {
|
||||||
|
maxBudget = pro25MaxBudget
|
||||||
|
} else {
|
||||||
|
maxBudget = flash25MaxBudget
|
||||||
|
}
|
||||||
|
switch effort {
|
||||||
|
case "high":
|
||||||
|
maxBudget = maxBudget * 80 / 100
|
||||||
|
case "medium":
|
||||||
|
maxBudget = maxBudget * 50 / 100
|
||||||
|
case "low":
|
||||||
|
maxBudget = maxBudget * 20 / 100
|
||||||
|
case "minimal":
|
||||||
|
maxBudget = maxBudget * 5 / 100
|
||||||
|
}
|
||||||
|
return clampThinkingBudget(modelName, maxBudget)
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package gemini
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var geminiOpenAPISchemaAllowedFields = map[string]struct{}{
|
||||||
|
"anyOf": {},
|
||||||
|
"default": {},
|
||||||
|
"description": {},
|
||||||
|
"enum": {},
|
||||||
|
"example": {},
|
||||||
|
"format": {},
|
||||||
|
"items": {},
|
||||||
|
"maxItems": {},
|
||||||
|
"maxLength": {},
|
||||||
|
"maxProperties": {},
|
||||||
|
"maximum": {},
|
||||||
|
"minItems": {},
|
||||||
|
"minLength": {},
|
||||||
|
"minProperties": {},
|
||||||
|
"minimum": {},
|
||||||
|
"nullable": {},
|
||||||
|
"pattern": {},
|
||||||
|
"properties": {},
|
||||||
|
"propertyOrdering": {},
|
||||||
|
"required": {},
|
||||||
|
"title": {},
|
||||||
|
"type": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const geminiFunctionSchemaMaxDepth = 64
|
||||||
|
|
||||||
|
func CleanFunctionParameters(params interface{}) interface{} {
|
||||||
|
return cleanGeminiFunctionParametersWithDepth(params, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanGeminiFunctionParametersWithDepth(params interface{}, depth int) interface{} {
|
||||||
|
if params == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if depth >= geminiFunctionSchemaMaxDepth {
|
||||||
|
return cleanGeminiFunctionParametersShallow(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := params.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
cleanedMap := make(map[string]interface{}, len(v))
|
||||||
|
for key, val := range v {
|
||||||
|
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
|
||||||
|
cleanedMap[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||||
|
|
||||||
|
if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
|
||||||
|
cleanedProps := make(map[string]interface{})
|
||||||
|
for propName, propValue := range props {
|
||||||
|
cleanedProps[propName] = cleanGeminiFunctionParametersWithDepth(propValue, depth+1)
|
||||||
|
}
|
||||||
|
cleanedMap["properties"] = cleanedProps
|
||||||
|
}
|
||||||
|
|
||||||
|
if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
|
||||||
|
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(items, depth+1)
|
||||||
|
}
|
||||||
|
if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
|
||||||
|
cleanedMap["items"] = cleanGeminiFunctionParametersWithDepth(itemsArray[0], depth+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
|
||||||
|
cleanedNested := make([]interface{}, len(nested))
|
||||||
|
for i, item := range nested {
|
||||||
|
cleanedNested[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
|
||||||
|
}
|
||||||
|
cleanedMap["anyOf"] = cleanedNested
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedMap
|
||||||
|
case []interface{}:
|
||||||
|
cleanedArray := make([]interface{}, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
cleanedArray[i] = cleanGeminiFunctionParametersWithDepth(item, depth+1)
|
||||||
|
}
|
||||||
|
return cleanedArray
|
||||||
|
default:
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanGeminiFunctionParametersShallow(params interface{}) interface{} {
|
||||||
|
switch v := params.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
cleanedMap := make(map[string]interface{}, len(v))
|
||||||
|
for key, val := range v {
|
||||||
|
if _, ok := geminiOpenAPISchemaAllowedFields[key]; ok {
|
||||||
|
cleanedMap[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizeGeminiSchemaTypeAndNullable(cleanedMap)
|
||||||
|
delete(cleanedMap, "properties")
|
||||||
|
delete(cleanedMap, "items")
|
||||||
|
delete(cleanedMap, "anyOf")
|
||||||
|
return cleanedMap
|
||||||
|
case []interface{}:
|
||||||
|
return []interface{}{}
|
||||||
|
default:
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
|
||||||
|
rawType, ok := schema["type"]
|
||||||
|
if !ok || rawType == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize := func(t string) (string, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||||
|
case "object":
|
||||||
|
return "OBJECT", false
|
||||||
|
case "array":
|
||||||
|
return "ARRAY", false
|
||||||
|
case "string":
|
||||||
|
return "STRING", false
|
||||||
|
case "integer":
|
||||||
|
return "INTEGER", false
|
||||||
|
case "number":
|
||||||
|
return "NUMBER", false
|
||||||
|
case "boolean":
|
||||||
|
return "BOOLEAN", false
|
||||||
|
case "null":
|
||||||
|
return "", true
|
||||||
|
default:
|
||||||
|
return t, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch typed := rawType.(type) {
|
||||||
|
case string:
|
||||||
|
normalized, isNull := normalize(typed)
|
||||||
|
if isNull {
|
||||||
|
schema["nullable"] = true
|
||||||
|
delete(schema, "type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
schema["type"] = normalized
|
||||||
|
case []interface{}:
|
||||||
|
nullable := false
|
||||||
|
var chosen string
|
||||||
|
for _, item := range typed {
|
||||||
|
if value, ok := item.(string); ok {
|
||||||
|
normalized, isNull := normalize(value)
|
||||||
|
if isNull {
|
||||||
|
nullable = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chosen == "" {
|
||||||
|
chosen = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nullable {
|
||||||
|
schema["nullable"] = true
|
||||||
|
}
|
||||||
|
if chosen != "" {
|
||||||
|
schema["type"] = chosen
|
||||||
|
} else {
|
||||||
|
delete(schema, "type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveAdditionalProperties(schema interface{}, depth int) interface{} {
|
||||||
|
if depth >= 5 {
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := schema.(map[string]interface{})
|
||||||
|
if !ok || len(value) == 0 {
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
delete(value, "title")
|
||||||
|
delete(value, "$schema")
|
||||||
|
if typeVal, exists := value["type"]; !exists || (typeVal != "object" && typeVal != "array") {
|
||||||
|
return schema
|
||||||
|
}
|
||||||
|
switch value["type"] {
|
||||||
|
case "object":
|
||||||
|
delete(value, "additionalProperties")
|
||||||
|
if properties, ok := value["properties"].(map[string]interface{}); ok {
|
||||||
|
for key, nested := range properties {
|
||||||
|
properties[key] = RemoveAdditionalProperties(nested, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, field := range []string{"allOf", "anyOf", "oneOf"} {
|
||||||
|
if nested, ok := value[field].([]interface{}); ok {
|
||||||
|
for i, item := range nested {
|
||||||
|
nested[i] = RemoveAdditionalProperties(item, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "array":
|
||||||
|
if items, ok := value["items"].(map[string]interface{}); ok {
|
||||||
|
value["items"] = RemoveAdditionalProperties(items, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIToolChoiceToConfig(toolChoice any) *dto.ToolConfig {
|
||||||
|
if toolChoice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolChoiceStr, ok := toolChoice.(string); ok {
|
||||||
|
config := &dto.ToolConfig{
|
||||||
|
FunctionCallingConfig: &dto.FunctionCallingConfig{},
|
||||||
|
}
|
||||||
|
switch toolChoiceStr {
|
||||||
|
case "auto":
|
||||||
|
config.FunctionCallingConfig.Mode = "AUTO"
|
||||||
|
case "none":
|
||||||
|
config.FunctionCallingConfig.Mode = "NONE"
|
||||||
|
case "required":
|
||||||
|
config.FunctionCallingConfig.Mode = "ANY"
|
||||||
|
default:
|
||||||
|
config.FunctionCallingConfig.Mode = "AUTO"
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
|
||||||
|
if toolChoiceMap["type"] == "function" {
|
||||||
|
config := &dto.ToolConfig{
|
||||||
|
FunctionCallingConfig: &dto.FunctionCallingConfig{
|
||||||
|
Mode: "ANY",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
|
||||||
|
if name, ok := function["name"].(string); ok && name != "" {
|
||||||
|
config.FunctionCallingConfig.AllowedFunctionNames = []string{name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import relaymedia "github.com/QuantumNous/new-api/service/relayconvert/internal/media"
|
||||||
|
|
||||||
|
type MediaResolver = relaymedia.MediaResolver
|
||||||
|
|
||||||
|
func SetMediaResolver(resolver MediaResolver) {
|
||||||
|
relaymedia.SetMediaResolver(resolver)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
claudemessages "github.com/QuantumNous/new-api/service/relayconvert/internal/claude_messages"
|
||||||
|
geminichat "github.com/QuantumNous/new-api/service/relayconvert/internal/gemini_chat"
|
||||||
|
oaichat "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat"
|
||||||
|
oairesponses "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_responses"
|
||||||
|
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
return claudemessages.ClaudeMessagesRequestToOpenAIChat(claudeRequest, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIChatRequestToClaudeMessages(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||||
|
return oaichat.OpenAIChatRequestToClaudeMessages(c, textRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIChatRequestToGeminiGenerateContent(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||||
|
return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, textRequest, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyGeminiThinkingConfig(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
|
||||||
|
sharedgemini.ApplyThinkingConfig(geminiRequest, info, oaiRequest...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*dto.OpenAIResponsesRequest, error) {
|
||||||
|
return oaichat.ChatCompletionsRequestToResponsesRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
return oairesponses.ResponsesRequestToChatCompletionsRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIResponsesRequestToClaudeMessages(c *gin.Context, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
|
||||||
|
return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenAIResponsesRequestToGeminiChat(c *gin.Context, req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) {
|
||||||
|
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, req, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShouldChatCompletionsUseResponsesPolicy(policy model_setting.ChatCompletionsToResponsesPolicy, channelID int, channelType int, model string) bool {
|
||||||
|
return oaichat.ShouldChatCompletionsUseResponsesPolicy(policy, channelID, channelType, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
|
||||||
|
return oaichat.ShouldChatCompletionsUseResponsesGlobal(channelID, channelType, model)
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
claudemessages "github.com/QuantumNous/new-api/service/relayconvert/internal/claude_messages"
|
||||||
|
geminichat "github.com/QuantumNous/new-api/service/relayconvert/internal/gemini_chat"
|
||||||
|
oaichat "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat"
|
||||||
|
oairesponses "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_responses"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RequestConverterFunc func(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error)
|
||||||
|
|
||||||
|
type RequestConverterQuality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RequestConverterQualityGood RequestConverterQuality = "good"
|
||||||
|
RequestConverterQualityFair RequestConverterQuality = "fair"
|
||||||
|
RequestConverterQualityDiscouraged RequestConverterQuality = "discouraged"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RequestStep struct {
|
||||||
|
Converter string
|
||||||
|
From types.RelayFormat
|
||||||
|
To types.RelayFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestResult struct {
|
||||||
|
Value any
|
||||||
|
From types.RelayFormat
|
||||||
|
To types.RelayFormat
|
||||||
|
Converter string
|
||||||
|
Quality RequestConverterQuality
|
||||||
|
Steps []RequestStep
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestConverterSpec struct {
|
||||||
|
ID string
|
||||||
|
From types.RelayFormat
|
||||||
|
To types.RelayFormat
|
||||||
|
Quality RequestConverterQuality
|
||||||
|
Convert RequestConverterFunc
|
||||||
|
StepConverters []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestConverterRoute struct {
|
||||||
|
from types.RelayFormat
|
||||||
|
to types.RelayFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
requestConverterMu sync.RWMutex
|
||||||
|
requestConverters = make(map[string]RequestConverterSpec)
|
||||||
|
requestConverterRoutes = make(map[requestConverterRoute]string)
|
||||||
|
requestConverterDirectRoutes = make(map[requestConverterRoute]string)
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
requestConverterClaudeToGemini = "claude_messages_to_gemini_generate_content"
|
||||||
|
requestConverterClaudeToResponses = "claude_messages_to_openai_responses"
|
||||||
|
requestConverterGeminiToClaude = "gemini_generate_content_to_claude_messages"
|
||||||
|
requestConverterGeminiToResponses = "gemini_generate_content_to_openai_responses"
|
||||||
|
requestConverterResponsesToClaude = "openai_responses_to_claude_messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConverterNone = "none"
|
||||||
|
ConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
|
||||||
|
ConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
|
||||||
|
ConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
|
||||||
|
ConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
|
||||||
|
ConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
|
||||||
|
ConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerBuiltinRequestConverter(spec RequestConverterSpec) {
|
||||||
|
spec.ID = strings.TrimSpace(spec.ID)
|
||||||
|
if spec.ID == "" {
|
||||||
|
panic("request converter ID is required")
|
||||||
|
}
|
||||||
|
if spec.From == "" || spec.To == "" {
|
||||||
|
panic(fmt.Sprintf("request converter %q must declare from and to formats", spec.ID))
|
||||||
|
}
|
||||||
|
if spec.Quality == "" {
|
||||||
|
panic(fmt.Sprintf("request converter %q must declare quality", spec.ID))
|
||||||
|
}
|
||||||
|
if spec.Convert == nil && len(spec.StepConverters) == 0 {
|
||||||
|
panic(fmt.Sprintf("request converter %q must declare convert or step converters", spec.ID))
|
||||||
|
}
|
||||||
|
if spec.Convert != nil && len(spec.StepConverters) > 0 {
|
||||||
|
panic(fmt.Sprintf("request converter %q cannot declare convert and step converters together", spec.ID))
|
||||||
|
}
|
||||||
|
if _, exists := requestConverters[spec.ID]; exists {
|
||||||
|
panic(fmt.Sprintf("request converter %q is already registered", spec.ID))
|
||||||
|
}
|
||||||
|
route := requestConverterRoute{from: spec.From, to: spec.To}
|
||||||
|
if existingID, exists := requestConverterRoutes[route]; exists {
|
||||||
|
panic(fmt.Sprintf("request converter route from %s to %s is already registered by %q", spec.From, spec.To, existingID))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(spec.StepConverters) > 0 {
|
||||||
|
stepConverters := make([]string, 0, len(spec.StepConverters))
|
||||||
|
current := spec.From
|
||||||
|
for _, converterID := range spec.StepConverters {
|
||||||
|
step, ok := requestConverters[converterID]
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("request converter %q references unknown step converter %q", spec.ID, converterID))
|
||||||
|
}
|
||||||
|
if step.Convert == nil || len(step.StepConverters) > 0 {
|
||||||
|
panic(fmt.Sprintf("request converter %q step %q must be a direct converter", spec.ID, converterID))
|
||||||
|
}
|
||||||
|
if step.From != current {
|
||||||
|
panic(fmt.Sprintf("request converter %q step %q expects %s after %s", spec.ID, converterID, step.From, current))
|
||||||
|
}
|
||||||
|
stepConverters = append(stepConverters, converterID)
|
||||||
|
current = step.To
|
||||||
|
}
|
||||||
|
if current != spec.To {
|
||||||
|
panic(fmt.Sprintf("request converter %q ends at %s, expected %s", spec.ID, current, spec.To))
|
||||||
|
}
|
||||||
|
spec.StepConverters = stepConverters
|
||||||
|
}
|
||||||
|
|
||||||
|
requestConverters[spec.ID] = spec
|
||||||
|
requestConverterRoutes[route] = spec.ID
|
||||||
|
if len(spec.StepConverters) == 0 {
|
||||||
|
requestConverterDirectRoutes[route] = spec.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupRequestConverter(converter string) (RequestConverterSpec, bool) {
|
||||||
|
requestConverterMu.RLock()
|
||||||
|
defer requestConverterMu.RUnlock()
|
||||||
|
|
||||||
|
spec, ok := requestConverters[strings.TrimSpace(converter)]
|
||||||
|
if !ok {
|
||||||
|
return RequestConverterSpec{}, false
|
||||||
|
}
|
||||||
|
return cloneRequestConverterSpec(spec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, request any) (*RequestResult, error) {
|
||||||
|
from, err := inferRequestRelayFormat(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
|
return nil, errors.New("target relay format is required")
|
||||||
|
}
|
||||||
|
if from == target {
|
||||||
|
return &RequestResult{
|
||||||
|
Value: request,
|
||||||
|
From: from,
|
||||||
|
To: target,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, ok := lookupRequestRoute(from, target)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("request converter from %s to %s is not registered", from, target)
|
||||||
|
}
|
||||||
|
return executeRequestSpec(c, info, from, target, request, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertRequestVia(c *gin.Context, info *relaycommon.RelayInfo, request any, path ...types.RelayFormat) (*RequestResult, error) {
|
||||||
|
from, err := inferRequestRelayFormat(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(path) == 0 {
|
||||||
|
return nil, errors.New("request conversion path is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]types.RelayFormat, 0, len(path))
|
||||||
|
for _, format := range path {
|
||||||
|
if format == "" {
|
||||||
|
return nil, errors.New("request conversion path contains empty relay format")
|
||||||
|
}
|
||||||
|
targets = append(targets, format)
|
||||||
|
}
|
||||||
|
if targets[0] == from {
|
||||||
|
targets = targets[1:]
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return &RequestResult{
|
||||||
|
Value: request,
|
||||||
|
From: from,
|
||||||
|
To: from,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := make([]RequestConverterSpec, 0, len(targets))
|
||||||
|
current := from
|
||||||
|
for _, target := range targets {
|
||||||
|
spec, ok := lookupRequestDirectRoute(current, target)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("request converter from %s to %s is not registered", current, target)
|
||||||
|
}
|
||||||
|
steps = append(steps, spec)
|
||||||
|
current = target
|
||||||
|
}
|
||||||
|
return executeRequestSteps(c, info, from, targets[len(targets)-1], request, "", "", steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertRequestByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (*RequestResult, error) {
|
||||||
|
from, err := inferRequestRelayFormat(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, ok := LookupRequestConverter(converter)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("request converter %q is not registered", strings.TrimSpace(converter))
|
||||||
|
}
|
||||||
|
if spec.From != "" && spec.From != from {
|
||||||
|
return nil, fmt.Errorf("request converter %q expects %s request, got %s", spec.ID, spec.From, from)
|
||||||
|
}
|
||||||
|
return executeRequestSpec(c, info, from, spec.To, request, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeRequestSpec(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, request any, spec RequestConverterSpec) (*RequestResult, error) {
|
||||||
|
steps, err := expandRequestConverterSteps(spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return executeRequestSteps(c, info, from, target, request, spec.ID, spec.Quality, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeRequestSteps(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, request any, converter string, quality RequestConverterQuality, specs []RequestConverterSpec) (*RequestResult, error) {
|
||||||
|
current := request
|
||||||
|
steps := make([]RequestStep, 0, len(specs))
|
||||||
|
for _, spec := range specs {
|
||||||
|
var err error
|
||||||
|
current, err = prepareRequestForStep(current, spec, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var step RequestStep
|
||||||
|
current, step, err = executeRequestStep(c, info, spec, current)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
steps = append(steps, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
converters := make([]string, 0, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
converters = append(converters, step.Converter)
|
||||||
|
}
|
||||||
|
if converter == "" {
|
||||||
|
converter = strings.Join(converters, ",")
|
||||||
|
}
|
||||||
|
return &RequestResult{
|
||||||
|
Value: current,
|
||||||
|
From: from,
|
||||||
|
To: target,
|
||||||
|
Converter: converter,
|
||||||
|
Quality: quality,
|
||||||
|
Steps: steps,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandRequestConverterSteps(spec RequestConverterSpec) ([]RequestConverterSpec, error) {
|
||||||
|
if len(spec.StepConverters) == 0 {
|
||||||
|
if spec.Convert == nil {
|
||||||
|
return nil, fmt.Errorf("request converter %q has no registered implementation", spec.ID)
|
||||||
|
}
|
||||||
|
return []RequestConverterSpec{spec}, nil
|
||||||
|
}
|
||||||
|
if spec.Convert != nil {
|
||||||
|
return nil, fmt.Errorf("request converter %q cannot mix direct and step conversion", spec.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := make([]RequestConverterSpec, 0, len(spec.StepConverters))
|
||||||
|
current := spec.From
|
||||||
|
for _, converterID := range spec.StepConverters {
|
||||||
|
step, ok := LookupRequestConverter(converterID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("request converter %q references missing step converter %q", spec.ID, converterID)
|
||||||
|
}
|
||||||
|
if step.Convert == nil || len(step.StepConverters) > 0 {
|
||||||
|
return nil, fmt.Errorf("request converter %q step %q is not a direct converter", spec.ID, converterID)
|
||||||
|
}
|
||||||
|
if step.From != current {
|
||||||
|
return nil, fmt.Errorf("request converter %q step %q expects %s request, got %s", spec.ID, converterID, step.From, current)
|
||||||
|
}
|
||||||
|
steps = append(steps, step)
|
||||||
|
current = step.To
|
||||||
|
}
|
||||||
|
if current != spec.To {
|
||||||
|
return nil, fmt.Errorf("request converter %q ends at %s, expected %s", spec.ID, current, spec.To)
|
||||||
|
}
|
||||||
|
return steps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeRequestStep(c *gin.Context, info *relaycommon.RelayInfo, spec RequestConverterSpec, request any) (any, RequestStep, error) {
|
||||||
|
if spec.Convert == nil {
|
||||||
|
return nil, RequestStep{}, fmt.Errorf("request converter %q has no registered implementation", spec.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := spec.Convert(c, info, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, RequestStep{}, err
|
||||||
|
}
|
||||||
|
if info != nil {
|
||||||
|
info.AppendRequestConversion(spec.To)
|
||||||
|
}
|
||||||
|
return value, RequestStep{
|
||||||
|
Converter: spec.ID,
|
||||||
|
From: spec.From,
|
||||||
|
To: spec.To,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareRequestForStep(request any, spec RequestConverterSpec, finalTarget types.RelayFormat) (any, error) {
|
||||||
|
if spec.From != types.RelayFormatOpenAIResponses || finalTarget != types.RelayFormatGemini {
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||||
|
responsesRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if responsesRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &prepared, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupRequestRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
|
||||||
|
requestConverterMu.RLock()
|
||||||
|
defer requestConverterMu.RUnlock()
|
||||||
|
|
||||||
|
converterID, ok := requestConverterRoutes[requestConverterRoute{from: from, to: to}]
|
||||||
|
if !ok {
|
||||||
|
return RequestConverterSpec{}, false
|
||||||
|
}
|
||||||
|
spec, ok := requestConverters[converterID]
|
||||||
|
return cloneRequestConverterSpec(spec), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupRequestDirectRoute(from types.RelayFormat, to types.RelayFormat) (RequestConverterSpec, bool) {
|
||||||
|
requestConverterMu.RLock()
|
||||||
|
defer requestConverterMu.RUnlock()
|
||||||
|
|
||||||
|
converterID, ok := requestConverterDirectRoutes[requestConverterRoute{from: from, to: to}]
|
||||||
|
if !ok {
|
||||||
|
return RequestConverterSpec{}, false
|
||||||
|
}
|
||||||
|
spec, ok := requestConverters[converterID]
|
||||||
|
return cloneRequestConverterSpec(spec), ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRequestConverterSpec(spec RequestConverterSpec) RequestConverterSpec {
|
||||||
|
if len(spec.StepConverters) > 0 {
|
||||||
|
spec.StepConverters = append([]string{}, spec.StepConverters...)
|
||||||
|
}
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferRequestRelayFormat(request any) (types.RelayFormat, error) {
|
||||||
|
if isNilRequest(request) {
|
||||||
|
return "", errors.New("request is nil")
|
||||||
|
}
|
||||||
|
format, ok := relaycommon.GuessRelayFormatFromRequest(request)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("unsupported request type %T", request)
|
||||||
|
}
|
||||||
|
return format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNilRequest(request any) bool {
|
||||||
|
if request == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value := reflect.ValueOf(request)
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||||
|
return value.IsNil()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertChatRequestToResponses(_ *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
chatRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||||
|
chatRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chatRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||||
|
}
|
||||||
|
return oaichat.ChatCompletionsRequestToResponsesRequest(chatRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertClaudeRequestToOpenAI(_ *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
claudeRequest, ok := request.(*dto.ClaudeRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.ClaudeRequest); ok {
|
||||||
|
claudeRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if claudeRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", request)
|
||||||
|
}
|
||||||
|
return claudemessages.ClaudeMessagesRequestToOpenAIChat(*claudeRequest, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertOpenAIRequestToClaude(c *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||||
|
openAIRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if openAIRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||||
|
}
|
||||||
|
return oaichat.OpenAIChatRequestToClaudeMessages(c, *openAIRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertGeminiRequestToOpenAI(_ *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
geminiRequest, ok := request.(*dto.GeminiChatRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.GeminiChatRequest); ok {
|
||||||
|
geminiRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if geminiRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
|
||||||
|
}
|
||||||
|
return geminichat.GeminiGenerateContentRequestToOpenAIChat(geminiRequest, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertOpenAIRequestToGemini(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
openAIRequest, ok := request.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.GeneralOpenAIRequest); ok {
|
||||||
|
openAIRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if openAIRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||||
|
}
|
||||||
|
return oaichat.OpenAIChatRequestToGeminiGenerateContent(c, *openAIRequest, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertOpenAIResponsesRequestToClaudeMessages(c *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
responsesRequest, err := oairesponses.OpenAIResponsesRequestFromAny(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return oairesponses.OpenAIResponsesRequestToClaudeMessages(c, responsesRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertOpenAIResponsesRequestToGeminiChat(c *gin.Context, info *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
responsesRequest, err := oairesponses.OpenAIResponsesRequestFromAny(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared, err := oairesponses.PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return oairesponses.OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertResponsesRequestToChat(_ *gin.Context, _ *relaycommon.RelayInfo, request any) (any, error) {
|
||||||
|
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||||
|
if !ok {
|
||||||
|
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||||
|
responsesRequest = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if responsesRequest == nil {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||||
|
}
|
||||||
|
return oairesponses.ResponsesRequestToChatCompletionsRequest(responsesRequest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/constant"
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
sharedgemini "github.com/QuantumNous/new-api/service/relayconvert/internal/shared/gemini"
|
||||||
|
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestConverterRegistryListsSupportedTextConverters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
converter string
|
||||||
|
from types.RelayFormat
|
||||||
|
to types.RelayFormat
|
||||||
|
quality RequestConverterQuality
|
||||||
|
stepConverters []string
|
||||||
|
advancedCustom bool
|
||||||
|
}{
|
||||||
|
{converter: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||||
|
{converter: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||||
|
{converter: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||||
|
{converter: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: RequestConverterQualityFair, advancedCustom: true},
|
||||||
|
{converter: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: RequestConverterQualityGood, advancedCustom: true},
|
||||||
|
{converter: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: RequestConverterQualityGood, advancedCustom: true},
|
||||||
|
{
|
||||||
|
converter: requestConverterClaudeToGemini,
|
||||||
|
from: types.RelayFormatClaude,
|
||||||
|
to: types.RelayFormatGemini,
|
||||||
|
quality: RequestConverterQualityDiscouraged,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
converter: requestConverterClaudeToResponses,
|
||||||
|
from: types.RelayFormatClaude,
|
||||||
|
to: types.RelayFormatOpenAIResponses,
|
||||||
|
quality: RequestConverterQualityFair,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
converter: requestConverterGeminiToClaude,
|
||||||
|
from: types.RelayFormatGemini,
|
||||||
|
to: types.RelayFormatClaude,
|
||||||
|
quality: RequestConverterQualityDiscouraged,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
converter: requestConverterGeminiToResponses,
|
||||||
|
from: types.RelayFormatGemini,
|
||||||
|
to: types.RelayFormatOpenAIResponses,
|
||||||
|
quality: RequestConverterQualityFair,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
converter: requestConverterResponsesToClaude,
|
||||||
|
from: types.RelayFormatOpenAIResponses,
|
||||||
|
to: types.RelayFormatClaude,
|
||||||
|
quality: RequestConverterQualityFair,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
converter: ConverterOpenAIResponsesToGemini,
|
||||||
|
from: types.RelayFormatOpenAIResponses,
|
||||||
|
to: types.RelayFormatGemini,
|
||||||
|
quality: RequestConverterQualityFair,
|
||||||
|
advancedCustom: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, requestConverters, len(tests))
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.converter, func(t *testing.T) {
|
||||||
|
spec, ok := LookupRequestConverter(tt.converter)
|
||||||
|
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, tt.converter, spec.ID)
|
||||||
|
assert.Equal(t, tt.from, spec.From)
|
||||||
|
assert.Equal(t, tt.to, spec.To)
|
||||||
|
assert.Equal(t, tt.quality, spec.Quality)
|
||||||
|
assert.Equal(t, tt.stepConverters, spec.StepConverters)
|
||||||
|
if len(tt.stepConverters) == 0 {
|
||||||
|
assert.NotNil(t, spec.Convert)
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, spec.Convert)
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.advancedCustom, dto.IsAdvancedCustomConverterAllowed(tt.converter))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestToTargetRecordsConversionChain(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||||
|
}
|
||||||
|
req := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||||
|
assert.Equal(t, types.RelayFormatOpenAI, result.From)
|
||||||
|
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), result.To)
|
||||||
|
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, result.Converter)
|
||||||
|
assert.Equal(t, RequestConverterQualityGood, result.Quality)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestPlansMultiHopPath(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatClaude,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatClaude},
|
||||||
|
}
|
||||||
|
req := &dto.ClaudeRequest{
|
||||||
|
Model: "claude-test",
|
||||||
|
Messages: []dto.ClaudeMessage{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAIResponses, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||||
|
assert.Equal(t, types.RelayFormat(types.RelayFormatClaude), result.From)
|
||||||
|
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), result.To)
|
||||||
|
assert.Equal(t, requestConverterClaudeToResponses, result.Converter)
|
||||||
|
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
From: types.RelayFormatClaude,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestViaExecutesExplicitPath(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||||
|
}
|
||||||
|
req := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequestVia(nil, info, req, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestResponsesToGeminiAppliesResponsesPreprocess(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := &dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "next turn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom_tool_call",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"name": "apply_patch",
|
||||||
|
"input": "patch body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "custom_tool_call_output",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"output": "ok",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_custom",
|
||||||
|
"output": "legacy custom output",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tools: mustRawMessage(t, []map[string]any{
|
||||||
|
{"type": "custom", "name": "apply_patch"},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Empty(t, geminiReq.GetTools())
|
||||||
|
require.Len(t, geminiReq.Contents, 1)
|
||||||
|
assert.Equal(t, "user", geminiReq.Contents[0].Role)
|
||||||
|
require.Len(t, geminiReq.Contents[0].Parts, 1)
|
||||||
|
assert.Equal(t, "next turn", geminiReq.Contents[0].Parts[0].Text)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||||
|
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIResponsesToGemini,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatGemini,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.RequestConversionChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestResponsesToGeminiUsesDirectConverter(t *testing.T) {
|
||||||
|
geminiSettings := model_setting.GetGeminiSettings()
|
||||||
|
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = true
|
||||||
|
t.Cleanup(func() {
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
maxOutputTokens := uint(256)
|
||||||
|
req := &dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Instructions: mustRawMessage(t, "system rules"),
|
||||||
|
MaxOutputTokens: &maxOutputTokens,
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "output_text", "text": "I will call."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "lookup",
|
||||||
|
"arguments": map[string]any{"q": "x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": map[string]any{"ok": true},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tools: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "lookup",
|
||||||
|
"description": "Lookup data",
|
||||||
|
"parameters": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"propertyNames": map[string]any{"pattern": "^[a-z]+$"},
|
||||||
|
"properties": map[string]any{
|
||||||
|
"q": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"exclusiveMinimum": 0,
|
||||||
|
},
|
||||||
|
"filters": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": map[string]any{
|
||||||
|
"name": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Text: mustRawMessage(t, map[string]any{
|
||||||
|
"format": map[string]any{
|
||||||
|
"type": "json_schema",
|
||||||
|
"name": "answer",
|
||||||
|
"schema": map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIResponsesToGemini,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatGemini,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatGemini}, info.RequestConversionChain)
|
||||||
|
|
||||||
|
require.NotNil(t, geminiReq.SystemInstructions)
|
||||||
|
require.Len(t, geminiReq.SystemInstructions.Parts, 1)
|
||||||
|
assert.Equal(t, "system rules", geminiReq.SystemInstructions.Parts[0].Text)
|
||||||
|
assert.Equal(t, "application/json", geminiReq.GenerationConfig.ResponseMimeType)
|
||||||
|
assert.Equal(t, maxOutputTokens, *geminiReq.GenerationConfig.MaxOutputTokens)
|
||||||
|
|
||||||
|
tools := geminiReq.GetTools()
|
||||||
|
require.Len(t, tools, 1)
|
||||||
|
functions, err := common.Any2Type[[]dto.FunctionRequest](tools[0].FunctionDeclarations)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, functions, 1)
|
||||||
|
assert.Equal(t, "lookup", functions[0].Name)
|
||||||
|
params, ok := functions[0].Parameters.(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "OBJECT", params["type"])
|
||||||
|
assert.NotContains(t, params, "additionalProperties")
|
||||||
|
assert.NotContains(t, params, "propertyNames")
|
||||||
|
properties, ok := params["properties"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
queryParam, ok := properties["q"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "STRING", queryParam["type"])
|
||||||
|
assert.NotContains(t, queryParam, "exclusiveMinimum")
|
||||||
|
filterParam, ok := properties["filters"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
filterItems, ok := filterParam["items"].(map[string]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.NotContains(t, filterItems, "additionalProperties")
|
||||||
|
|
||||||
|
require.Len(t, geminiReq.Contents, 2)
|
||||||
|
assert.Equal(t, "model", geminiReq.Contents[0].Role)
|
||||||
|
require.Len(t, geminiReq.Contents[0].Parts, 2)
|
||||||
|
functionCall := geminiReq.Contents[0].Parts[0].FunctionCall
|
||||||
|
require.NotNil(t, functionCall)
|
||||||
|
assert.Equal(t, "lookup", functionCall.FunctionName)
|
||||||
|
assert.Equal(t, map[string]any{"q": "x"}, functionCall.Arguments)
|
||||||
|
var thoughtSignature string
|
||||||
|
require.NoError(t, common.Unmarshal(geminiReq.Contents[0].Parts[0].ThoughtSignature, &thoughtSignature))
|
||||||
|
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
|
||||||
|
assert.Equal(t, "I will call.", geminiReq.Contents[0].Parts[1].Text)
|
||||||
|
|
||||||
|
assert.Equal(t, "user", geminiReq.Contents[1].Role)
|
||||||
|
require.Len(t, geminiReq.Contents[1].Parts, 1)
|
||||||
|
functionResponse := geminiReq.Contents[1].Parts[0].FunctionResponse
|
||||||
|
require.NotNil(t, functionResponse)
|
||||||
|
assert.Equal(t, "lookup", functionResponse.Name)
|
||||||
|
assert.Equal(t, true, functionResponse.Response["ok"])
|
||||||
|
assert.Empty(t, geminiReq.Contents[1].Parts[0].ThoughtSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestResponsesToGeminiSkipsThoughtSignatureWhenDisabled(t *testing.T) {
|
||||||
|
geminiSettings := model_setting.GetGeminiSettings()
|
||||||
|
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = false
|
||||||
|
t.Cleanup(func() {
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := &dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "lookup",
|
||||||
|
"arguments": map[string]any{"q": "x"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tools: mustRawMessage(t, []map[string]any{
|
||||||
|
{"type": "function", "name": "lookup", "parameters": map[string]any{"type": "object"}},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, geminiReq.Contents, 1)
|
||||||
|
require.Len(t, geminiReq.Contents[0].Parts, 1)
|
||||||
|
require.NotNil(t, geminiReq.Contents[0].Parts[0].FunctionCall)
|
||||||
|
assert.Empty(t, geminiReq.Contents[0].Parts[0].ThoughtSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestOpenAIChatToGeminiAddsThoughtSignatureForAdvancedCustom(t *testing.T) {
|
||||||
|
geminiSettings := model_setting.GetGeminiSettings()
|
||||||
|
originalThoughtSignatureEnabled := geminiSettings.FunctionCallThoughtSignatureEnabled
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = true
|
||||||
|
t.Cleanup(func() {
|
||||||
|
geminiSettings.FunctionCallThoughtSignatureEnabled = originalThoughtSignatureEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
assistantMessage := dto.Message{Role: "assistant", Content: ""}
|
||||||
|
assistantMessage.SetToolCalls([]dto.ToolCallRequest{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: `{"q":"x"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
ChannelType: constant.ChannelTypeAdvancedCustom,
|
||||||
|
UpstreamModelName: "gemini-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hi"},
|
||||||
|
assistantMessage,
|
||||||
|
{Role: "tool", ToolCallId: "call_1", Content: `{"ok":true}`},
|
||||||
|
},
|
||||||
|
Tools: []dto.ToolCallRequest{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: "lookup",
|
||||||
|
Parameters: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatGemini, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
geminiReq, ok := result.Value.(*dto.GeminiChatRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, geminiReq.Contents, 3)
|
||||||
|
assert.Equal(t, "model", geminiReq.Contents[1].Role)
|
||||||
|
require.Len(t, geminiReq.Contents[1].Parts, 1)
|
||||||
|
require.NotNil(t, geminiReq.Contents[1].Parts[0].FunctionCall)
|
||||||
|
var thoughtSignature string
|
||||||
|
require.NoError(t, common.Unmarshal(geminiReq.Contents[1].Parts[0].ThoughtSignature, &thoughtSignature))
|
||||||
|
assert.Equal(t, sharedgemini.ThoughtSignatureBypassValue, thoughtSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestResponsesToClaudeUsesDirectConverter(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||||
|
}
|
||||||
|
stream := true
|
||||||
|
parallelToolCalls := false
|
||||||
|
maxOutputTokens := uint(512)
|
||||||
|
req := &dto.OpenAIResponsesRequest{
|
||||||
|
Model: "claude-test",
|
||||||
|
Instructions: mustRawMessage(t, "system rules"),
|
||||||
|
Stream: &stream,
|
||||||
|
MaxOutputTokens: &maxOutputTokens,
|
||||||
|
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
|
||||||
|
Reasoning: &dto.Reasoning{Effort: "medium"},
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "question",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": []map[string]any{
|
||||||
|
{"type": "output_text", "text": "I will call."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "lookup",
|
||||||
|
"arguments": map[string]any{"q": "x"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": map[string]any{"ok": true},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tools: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"name": "lookup",
|
||||||
|
"description": "Lookup data",
|
||||||
|
"parameters": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"q": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatClaude, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
claudeReq, ok := result.Value.(*dto.ClaudeRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, requestConverterResponsesToClaude, result.Converter)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: requestConverterResponsesToClaude,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatClaude,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAIResponses, types.RelayFormatClaude}, info.RequestConversionChain)
|
||||||
|
|
||||||
|
system, err := common.Any2Type[[]dto.ClaudeMediaMessage](claudeReq.System)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, system, 1)
|
||||||
|
assert.Equal(t, "system rules", system[0].GetText())
|
||||||
|
require.NotNil(t, claudeReq.Stream)
|
||||||
|
assert.True(t, *claudeReq.Stream)
|
||||||
|
assert.Equal(t, maxOutputTokens, *claudeReq.MaxTokens)
|
||||||
|
require.NotNil(t, claudeReq.Thinking)
|
||||||
|
assert.Equal(t, "enabled", claudeReq.Thinking.Type)
|
||||||
|
assert.Equal(t, 2048, claudeReq.Thinking.GetBudgetTokens())
|
||||||
|
|
||||||
|
tools, err := common.Any2Type[[]*dto.Tool](claudeReq.Tools)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, tools, 1)
|
||||||
|
assert.Equal(t, "lookup", tools[0].Name)
|
||||||
|
|
||||||
|
require.Len(t, claudeReq.Messages, 3)
|
||||||
|
assert.Equal(t, "user", claudeReq.Messages[0].Role)
|
||||||
|
userParts, err := claudeReq.Messages[0].ParseContent()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, userParts, 1)
|
||||||
|
assert.Equal(t, "question", userParts[0].GetText())
|
||||||
|
|
||||||
|
assert.Equal(t, "assistant", claudeReq.Messages[1].Role)
|
||||||
|
assistantParts, err := claudeReq.Messages[1].ParseContent()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, assistantParts, 2)
|
||||||
|
assert.Equal(t, "I will call.", assistantParts[0].GetText())
|
||||||
|
assert.Equal(t, "tool_use", assistantParts[1].Type)
|
||||||
|
assert.Equal(t, "call_1", assistantParts[1].Id)
|
||||||
|
assert.Equal(t, "lookup", assistantParts[1].Name)
|
||||||
|
assert.Equal(t, map[string]any{"q": "x"}, assistantParts[1].Input)
|
||||||
|
|
||||||
|
assert.Equal(t, "user", claudeReq.Messages[2].Role)
|
||||||
|
toolResultParts, err := claudeReq.Messages[2].ParseContent()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, toolResultParts, 1)
|
||||||
|
assert.Equal(t, "tool_result", toolResultParts[0].Type)
|
||||||
|
assert.Equal(t, "call_1", toolResultParts[0].ToolUseId)
|
||||||
|
assert.Equal(t, map[string]any{"ok": true}, toolResultParts[0].Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestViaResponsesToGeminiStillUsesDirectSteps(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAIResponses,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAIResponses},
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req := &dto.OpenAIResponsesRequest{
|
||||||
|
Model: "gemini-test",
|
||||||
|
Input: mustRawMessage(t, []map[string]any{
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "hello",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequestVia(nil, info, req, types.RelayFormatOpenAI, types.RelayFormatGemini)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &dto.GeminiChatRequest{}, result.Value)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat+","+ConverterOpenAIChatToGeminiContent, result.Converter)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIChatToGeminiContent,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatGemini,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestByIDDeduplicatesConversionChain(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses},
|
||||||
|
}
|
||||||
|
req := &dto.GeneralOpenAIRequest{
|
||||||
|
Model: "gpt-test",
|
||||||
|
Messages: []dto.Message{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequestByID(nil, info, ConverterOpenAIChatToOpenAIResponses, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||||
|
require.Len(t, result.Steps, 1)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestByIDExecutesMultiHopConverter(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatClaude,
|
||||||
|
RequestConversionChain: []types.RelayFormat{types.RelayFormatClaude},
|
||||||
|
}
|
||||||
|
req := &dto.ClaudeRequest{
|
||||||
|
Model: "claude-test",
|
||||||
|
Messages: []dto.ClaudeMessage{
|
||||||
|
{Role: "user", Content: "hello"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ConvertRequestByID(nil, info, requestConverterClaudeToResponses, req)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.IsType(t, &dto.OpenAIResponsesRequest{}, result.Value)
|
||||||
|
assert.Equal(t, requestConverterClaudeToResponses, result.Converter)
|
||||||
|
assert.Equal(t, RequestConverterQualityFair, result.Quality)
|
||||||
|
assert.Equal(t, []RequestStep{
|
||||||
|
{
|
||||||
|
Converter: ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
From: types.RelayFormatClaude,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Converter: ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
},
|
||||||
|
}, result.Steps)
|
||||||
|
assert.Equal(t, []types.RelayFormat{types.RelayFormatClaude, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses}, info.RequestConversionChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestRejectsUnsupportedConverterAndNilRequest(t *testing.T) {
|
||||||
|
_, err := ConvertRequestByID(nil, &relaycommon.RelayInfo{}, "missing_converter", &dto.GeneralOpenAIRequest{Model: "gpt-test"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not registered")
|
||||||
|
|
||||||
|
_, err = ConvertRequest(nil, &relaycommon.RelayInfo{}, types.RelayFormatOpenAIResponses, (*dto.GeneralOpenAIRequest)(nil))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "request is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestByIDRejectsWrongSourceFormat(t *testing.T) {
|
||||||
|
_, err := ConvertRequestByID(
|
||||||
|
nil,
|
||||||
|
&relaycommon.RelayInfo{},
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
&dto.ClaudeRequest{Model: "claude-test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "expects openai request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertRequestRejectsUnregisteredExplicitPath(t *testing.T) {
|
||||||
|
_, err := ConvertRequest(
|
||||||
|
nil,
|
||||||
|
&relaycommon.RelayInfo{},
|
||||||
|
types.RelayFormatEmbedding,
|
||||||
|
&dto.ClaudeRequest{Model: "claude-test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "from claude to embedding is not registered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRawMessage(t *testing.T, value any) []byte {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := common.Marshal(value)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
claudemessages "github.com/QuantumNous/new-api/service/relayconvert/internal/claude_messages"
|
||||||
|
geminichat "github.com/QuantumNous/new-api/service/relayconvert/internal/gemini_chat"
|
||||||
|
oaichat "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_chat"
|
||||||
|
oairesponses "github.com/QuantumNous/new-api/service/relayconvert/internal/oai_responses"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaudeResponseInfo = claudemessages.ClaudeResponseInfo
|
||||||
|
|
||||||
|
type ChatToResponsesStreamEvent = oaichat.ChatToResponsesStreamEvent
|
||||||
|
type ChatToResponsesStreamState = oaichat.ChatToResponsesStreamState
|
||||||
|
type ResponsesToChatStreamState = oairesponses.ResponsesToChatStreamState
|
||||||
|
type ResponsesBufferedAccumulator = oairesponses.ResponsesBufferedAccumulator
|
||||||
|
|
||||||
|
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||||
|
return oaichat.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse {
|
||||||
|
return oaichat.ResponseOpenAI2Claude(openAIResponse, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||||
|
return oaichat.StreamResponseOpenAI2Claude(openAIResponse, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StopReasonClaudeToOpenAI(reason string) string {
|
||||||
|
return claudemessages.StopReasonClaudeToOpenAI(reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse {
|
||||||
|
return claudemessages.StreamResponseClaude2OpenAI(claudeResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
|
||||||
|
return claudemessages.ResponseClaude2OpenAI(claudeResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromClaudeAPIUsage(usage *dto.ClaudeUsage) *dto.Usage {
|
||||||
|
return claudemessages.UsageFromClaudeAPIUsage(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromClaudeUsage(usage *dto.Usage) *dto.Usage {
|
||||||
|
return claudemessages.UsageFromClaudeUsage(usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage {
|
||||||
|
return claudemessages.BuildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
|
||||||
|
return claudemessages.PatchClaudeMessageDeltaUsageData(data, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
||||||
|
return claudemessages.FormatClaudeResponseInfo(claudeResponse, oaiResponse, claudeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||||
|
return oaichat.ResponseOpenAI2Gemini(openAIResponse, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
|
||||||
|
return oaichat.StreamResponseOpenAI2Gemini(openAIResponse, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
|
||||||
|
return geminichat.UsageFromGeminiMetadata(metadata, fallbackPromptTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
|
||||||
|
return geminichat.ResponseGeminiChat2OpenAI(id, created, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
|
||||||
|
return geminichat.StreamResponseGeminiChat2OpenAI(geminiResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
|
||||||
|
return oaichat.ChatCompletionsResponseToResponsesResponse(resp, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
|
||||||
|
return oaichat.ResponsesStatusFromChatFinishReason(finishReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromChatUsage(src *dto.Usage) *dto.Usage {
|
||||||
|
return oaichat.UsageFromChatUsage(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
|
||||||
|
return oaichat.NewChatToResponsesStreamState(id, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
|
||||||
|
return oaichat.ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
|
||||||
|
return oaichat.FinalizeChatCompletionsStreamToResponses(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||||
|
return oairesponses.ResponsesFinishReasonFromStatus(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||||
|
return oairesponses.ResponsesResponseToChatCompletionsResponse(resp, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||||
|
return oairesponses.UsageFromResponsesUsage(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
return oairesponses.ExtractOutputTextFromResponses(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||||
|
return oairesponses.ExtractReasoningTextFromResponses(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
|
||||||
|
return oairesponses.NewResponsesToChatStreamState(model, includeUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||||
|
return oairesponses.ResponsesStreamEventToChatChunks(event, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
|
||||||
|
return oairesponses.FinalizeResponsesToChatStream(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
|
||||||
|
return oairesponses.NewResponsesBufferedAccumulator()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,668 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLookupBuiltinResponseConverters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
lookupID string
|
||||||
|
id string
|
||||||
|
from types.RelayFormat
|
||||||
|
to types.RelayFormat
|
||||||
|
quality ResponseConverterQuality
|
||||||
|
stepConverters []string
|
||||||
|
}{
|
||||||
|
{lookupID: ResponseConverterOAIChatToOAIResponses, id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: ResponseConverterQualityGood},
|
||||||
|
{lookupID: ResponseConverterOAIResponsesToOAIChat, id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityGood},
|
||||||
|
{lookupID: ResponseConverterOAIChatToClaudeMessages, id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: ResponseConverterQualityFair},
|
||||||
|
{lookupID: ResponseConverterOAIChatToGeminiChat, id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: ResponseConverterQualityFair},
|
||||||
|
{lookupID: ResponseConverterClaudeMessagesToOAIChat, id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
|
||||||
|
{lookupID: ResponseConverterGeminiChatToOAIChat, id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: ResponseConverterQualityFair},
|
||||||
|
{
|
||||||
|
lookupID: responseConverterClaudeToGemini,
|
||||||
|
id: requestConverterClaudeToGemini,
|
||||||
|
from: types.RelayFormatClaude,
|
||||||
|
to: types.RelayFormatGemini,
|
||||||
|
quality: ResponseConverterQualityDiscouraged,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lookupID: responseConverterClaudeToResponses,
|
||||||
|
id: requestConverterClaudeToResponses,
|
||||||
|
from: types.RelayFormatClaude,
|
||||||
|
to: types.RelayFormatOpenAIResponses,
|
||||||
|
quality: ResponseConverterQualityFair,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lookupID: responseConverterGeminiToClaude,
|
||||||
|
id: requestConverterGeminiToClaude,
|
||||||
|
from: types.RelayFormatGemini,
|
||||||
|
to: types.RelayFormatClaude,
|
||||||
|
quality: ResponseConverterQualityDiscouraged,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lookupID: responseConverterGeminiToResponses,
|
||||||
|
id: requestConverterGeminiToResponses,
|
||||||
|
from: types.RelayFormatGemini,
|
||||||
|
to: types.RelayFormatOpenAIResponses,
|
||||||
|
quality: ResponseConverterQualityFair,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lookupID: responseConverterResponsesToClaude,
|
||||||
|
id: requestConverterResponsesToClaude,
|
||||||
|
from: types.RelayFormatOpenAIResponses,
|
||||||
|
to: types.RelayFormatClaude,
|
||||||
|
quality: ResponseConverterQualityFair,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lookupID: responseConverterResponsesToGemini,
|
||||||
|
id: ConverterOpenAIResponsesToGemini,
|
||||||
|
from: types.RelayFormatOpenAIResponses,
|
||||||
|
to: types.RelayFormatGemini,
|
||||||
|
quality: ResponseConverterQualityFair,
|
||||||
|
stepConverters: []string{
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.lookupID, func(t *testing.T) {
|
||||||
|
spec, ok := LookupResponseConverter(tt.lookupID)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, tt.id, spec.ID)
|
||||||
|
assert.Equal(t, tt.from, spec.From)
|
||||||
|
assert.Equal(t, tt.to, spec.To)
|
||||||
|
assert.Equal(t, tt.quality, spec.Quality)
|
||||||
|
assert.Equal(t, tt.stepConverters, spec.StepConverters)
|
||||||
|
if len(tt.stepConverters) == 0 {
|
||||||
|
assert.NotNil(t, spec.Convert)
|
||||||
|
} else {
|
||||||
|
assert.Nil(t, spec.Convert)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := LookupResponseConverter("missing")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponseRejectsNilAndUnsupportedRoute(t *testing.T) {
|
||||||
|
_, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, (*dto.OpenAITextResponse)(nil))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = ConvertResponse(nil, nil, types.RelayFormatEmbedding, &dto.OpenAITextResponse{})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponseDirectConverters(t *testing.T) {
|
||||||
|
chat := textRegistryChatResponse()
|
||||||
|
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}
|
||||||
|
|
||||||
|
toResponses, err := ConvertResponse(nil, info, types.RelayFormatOpenAIResponses, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, toResponses.Converter)
|
||||||
|
assert.Equal(t, ResponseConverterQualityGood, toResponses.Quality)
|
||||||
|
assert.Equal(t, types.RelayFormatOpenAI, toResponses.From)
|
||||||
|
assert.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), toResponses.To)
|
||||||
|
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, toResponses.Steps)
|
||||||
|
require.IsType(t, &dto.OpenAIResponsesResponse{}, toResponses.Value)
|
||||||
|
assert.Equal(t, 9, toResponses.Usage.TotalTokens)
|
||||||
|
require.NotNil(t, toResponses.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toResponses.Usage.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceOAIChat, toResponses.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, 4, toResponses.Usage.BillingUsage.OpenAIUsage.PromptTokens)
|
||||||
|
|
||||||
|
responses := &dto.OpenAIResponsesResponse{
|
||||||
|
ID: "resp_1",
|
||||||
|
CreatedAt: 123,
|
||||||
|
Model: "gpt-test",
|
||||||
|
Status: []byte(`"completed"`),
|
||||||
|
Output: []dto.ResponsesOutput{
|
||||||
|
{
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{Type: "output_text", Text: "hello"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 6, TotalTokens: 10},
|
||||||
|
}
|
||||||
|
toChat, err := ConvertResponse(nil, info, types.RelayFormatOpenAI, responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, toChat.Converter)
|
||||||
|
assert.Equal(t, ResponseConverterQualityGood, toChat.Quality)
|
||||||
|
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
|
||||||
|
assert.Equal(t, 10, toChat.Usage.TotalTokens)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage.OpenAIUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceOAIResponses, toChat.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.BillingUsage.OpenAIUsage.InputTokens)
|
||||||
|
|
||||||
|
toClaude, err := ConvertResponse(nil, info, types.RelayFormatClaude, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, toClaude.Converter)
|
||||||
|
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
|
||||||
|
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
|
||||||
|
assert.Equal(t, 9, toClaude.Usage.TotalTokens)
|
||||||
|
require.NotNil(t, toClaude.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toClaude.Usage.BillingUsage.OpenAIUsage)
|
||||||
|
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
|
||||||
|
require.NotNil(t, claudeValue.Usage)
|
||||||
|
require.NotNil(t, claudeValue.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, claudeValue.Usage.BillingUsage.OpenAIUsage)
|
||||||
|
|
||||||
|
toGemini, err := ConvertResponse(nil, info, types.RelayFormatGemini, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterOpenAIChatToGeminiContent, toGemini.Converter)
|
||||||
|
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
|
||||||
|
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
|
||||||
|
assert.Equal(t, 9, toGemini.Usage.TotalTokens)
|
||||||
|
require.NotNil(t, toGemini.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toGemini.Usage.BillingUsage.OpenAIUsage)
|
||||||
|
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
|
||||||
|
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage)
|
||||||
|
require.NotNil(t, geminiValue.UsageMetadata.BillingUsage.OpenAIUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponseMultiHopConverters(t *testing.T) {
|
||||||
|
responses := textRegistryResponsesResponse()
|
||||||
|
|
||||||
|
toClaude, err := ConvertResponse(nil, &relaycommon.RelayInfo{}, types.RelayFormatClaude, responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, requestConverterResponsesToClaude, toClaude.Converter)
|
||||||
|
assert.Equal(t, ResponseConverterQualityFair, toClaude.Quality)
|
||||||
|
assert.Equal(t, []ResponseStep{
|
||||||
|
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||||
|
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
|
||||||
|
}, toClaude.Steps)
|
||||||
|
require.IsType(t, &dto.ClaudeResponse{}, toClaude.Value)
|
||||||
|
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
|
||||||
|
require.Len(t, claudeValue.Content, 2)
|
||||||
|
assert.Equal(t, "text", claudeValue.Content[0].Type)
|
||||||
|
assert.Equal(t, "tool_use", claudeValue.Content[1].Type)
|
||||||
|
assert.Equal(t, "lookup", claudeValue.Content[1].Name)
|
||||||
|
assert.Equal(t, map[string]interface{}{"q": "x"}, claudeValue.Content[1].Input)
|
||||||
|
assert.Equal(t, 11, toClaude.Usage.TotalTokens)
|
||||||
|
|
||||||
|
toGemini, err := ConvertResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatGemini, responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToGemini, toGemini.Converter)
|
||||||
|
assert.Equal(t, ResponseConverterQualityFair, toGemini.Quality)
|
||||||
|
assert.Equal(t, []ResponseStep{
|
||||||
|
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||||
|
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
|
||||||
|
}, toGemini.Steps)
|
||||||
|
require.IsType(t, &dto.GeminiChatResponse{}, toGemini.Value)
|
||||||
|
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
|
||||||
|
require.Len(t, geminiValue.Candidates, 1)
|
||||||
|
require.Len(t, geminiValue.Candidates[0].Content.Parts, 2)
|
||||||
|
assert.Equal(t, "hello", geminiValue.Candidates[0].Content.Parts[0].Text)
|
||||||
|
require.NotNil(t, geminiValue.Candidates[0].Content.Parts[1].FunctionCall)
|
||||||
|
assert.Equal(t, "lookup", geminiValue.Candidates[0].Content.Parts[1].FunctionCall.FunctionName)
|
||||||
|
assert.Equal(t, map[string]interface{}{"q": "x"}, geminiValue.Candidates[0].Content.Parts[1].FunctionCall.Arguments)
|
||||||
|
assert.Equal(t, 11, toGemini.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponseByIDExecutesMultiHopAndChecksSource(t *testing.T) {
|
||||||
|
responses := textRegistryResponsesResponse()
|
||||||
|
|
||||||
|
result, err := ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToGemini, result.Converter)
|
||||||
|
assert.Equal(t, []ResponseStep{
|
||||||
|
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||||
|
{Converter: ConverterOpenAIChatToGeminiContent, From: types.RelayFormatOpenAI, To: types.RelayFormatGemini},
|
||||||
|
}, result.Steps)
|
||||||
|
|
||||||
|
_, err = ConvertResponseByID(nil, nil, responseConverterResponsesToGemini, textRegistryChatResponse())
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponseProviderToOAIChatUsage(t *testing.T) {
|
||||||
|
claude := &dto.ClaudeResponse{
|
||||||
|
Id: "msg_1",
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: "claude-test",
|
||||||
|
StopReason: "end_turn",
|
||||||
|
Content: []dto.ClaudeMediaMessage{
|
||||||
|
{Type: "tool_use", Id: "toolu_1", Name: "lookup", Input: map[string]interface{}{"q": "x"}},
|
||||||
|
},
|
||||||
|
Usage: &dto.ClaudeUsage{
|
||||||
|
InputTokens: 10,
|
||||||
|
CacheReadInputTokens: 3,
|
||||||
|
CacheCreationInputTokens: 4,
|
||||||
|
OutputTokens: 5,
|
||||||
|
CacheCreation: &dto.ClaudeCacheCreationUsage{
|
||||||
|
Ephemeral5mInputTokens: 1,
|
||||||
|
Ephemeral1hInputTokens: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, claude)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterClaudeMessagesToOpenAIChat, toChat.Converter)
|
||||||
|
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
|
||||||
|
assert.Equal(t, 17, toChat.Usage.PromptTokens)
|
||||||
|
assert.Equal(t, 5, toChat.Usage.CompletionTokens)
|
||||||
|
assert.Equal(t, 22, toChat.Usage.TotalTokens)
|
||||||
|
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.CachedTokens)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, toChat.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticAnthropic, toChat.Usage.BillingUsage.Semantic)
|
||||||
|
assert.Equal(t, 10, toChat.Usage.BillingUsage.ClaudeUsage.InputTokens)
|
||||||
|
assert.Equal(t, 3, toChat.Usage.BillingUsage.ClaudeUsage.CacheReadInputTokens)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
|
||||||
|
assert.Equal(t, 5, toChat.Usage.BillingUsage.ClaudeUsage.OutputTokens)
|
||||||
|
chatValue := toChat.Value.(*dto.OpenAITextResponse)
|
||||||
|
require.Len(t, chatValue.Choices, 1)
|
||||||
|
require.Len(t, chatValue.Choices[0].Message.ParseToolCalls(), 1)
|
||||||
|
assert.JSONEq(t, `{"q":"x"}`, chatValue.Choices[0].Message.ParseToolCalls()[0].Function.Arguments)
|
||||||
|
|
||||||
|
gemini := &dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{
|
||||||
|
{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: "hello"},
|
||||||
|
{FunctionCall: &dto.FunctionCall{FunctionName: "lookup", Arguments: map[string]interface{}{"q": "x"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 7,
|
||||||
|
ToolUsePromptTokenCount: 2,
|
||||||
|
CandidatesTokenCount: 5,
|
||||||
|
ThoughtsTokenCount: 3,
|
||||||
|
TotalTokenCount: 17,
|
||||||
|
CachedContentTokenCount: 4,
|
||||||
|
PromptTokensDetails: []dto.GeminiPromptTokensDetails{
|
||||||
|
{Modality: "TEXT", TokenCount: 5},
|
||||||
|
{Modality: "IMAGE", TokenCount: 1},
|
||||||
|
},
|
||||||
|
ToolUsePromptTokensDetails: []dto.GeminiPromptTokensDetails{
|
||||||
|
{Modality: "AUDIO", TokenCount: 3},
|
||||||
|
},
|
||||||
|
CandidatesTokensDetails: []dto.GeminiPromptTokensDetails{
|
||||||
|
{Modality: "TEXT", TokenCount: 4},
|
||||||
|
{Modality: "IMAGE", TokenCount: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
toChat, err = ConvertResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatOpenAI, gemini)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ConverterGeminiContentToOpenAIChat, toChat.Converter)
|
||||||
|
require.IsType(t, &dto.OpenAITextResponse{}, toChat.Value)
|
||||||
|
assert.Equal(t, 9, toChat.Usage.PromptTokens)
|
||||||
|
assert.Equal(t, 8, toChat.Usage.CompletionTokens)
|
||||||
|
assert.Equal(t, 17, toChat.Usage.TotalTokens)
|
||||||
|
assert.Equal(t, 3, toChat.Usage.CompletionTokenDetails.ReasoningTokens)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.PromptTokensDetails.CachedTokens)
|
||||||
|
assert.Equal(t, 5, toChat.Usage.PromptTokensDetails.TextTokens)
|
||||||
|
assert.Equal(t, 3, toChat.Usage.PromptTokensDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 1, toChat.Usage.PromptTokensDetails.ImageTokens)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.CompletionTokenDetails.TextTokens)
|
||||||
|
assert.Equal(t, 1, toChat.Usage.CompletionTokenDetails.ImageTokens)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceGeminiChat, toChat.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticGemini, toChat.Usage.BillingUsage.Semantic)
|
||||||
|
assert.Equal(t, 7, toChat.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
assert.Equal(t, 2, toChat.Usage.BillingUsage.GeminiUsageMetadata.ToolUsePromptTokenCount)
|
||||||
|
assert.Equal(t, 17, toChat.Usage.BillingUsage.GeminiUsageMetadata.TotalTokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponsePreservesBillingUsageAcrossChatResponsesBridge(t *testing.T) {
|
||||||
|
chat := textRegistryChatResponse()
|
||||||
|
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
|
||||||
|
InputTokens: 10,
|
||||||
|
CacheReadInputTokens: 3,
|
||||||
|
CacheCreationInputTokens: 4,
|
||||||
|
OutputTokens: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
toResponses, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, toResponses.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toResponses.Usage.BillingUsage.ClaudeUsage)
|
||||||
|
assert.Equal(t, 10, toResponses.Usage.BillingUsage.ClaudeUsage.InputTokens)
|
||||||
|
|
||||||
|
responsesValue := toResponses.Value.(*dto.OpenAIResponsesResponse)
|
||||||
|
toChat, err := ConvertResponse(nil, nil, types.RelayFormatOpenAI, responsesValue)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage)
|
||||||
|
require.NotNil(t, toChat.Usage.BillingUsage.ClaudeUsage)
|
||||||
|
assert.Equal(t, 4, toChat.Usage.BillingUsage.ClaudeUsage.CacheCreationInputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertResponseUsesBillingUsageWhenRestoringNativeTargets(t *testing.T) {
|
||||||
|
chat := textRegistryChatResponse()
|
||||||
|
chat.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
|
||||||
|
InputTokens: 10,
|
||||||
|
CacheReadInputTokens: 3,
|
||||||
|
CacheCreationInputTokens: 4,
|
||||||
|
OutputTokens: 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
toClaude, err := ConvertResponse(nil, nil, types.RelayFormatClaude, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
claudeValue := toClaude.Value.(*dto.ClaudeResponse)
|
||||||
|
require.NotNil(t, claudeValue.Usage)
|
||||||
|
assert.Equal(t, 10, claudeValue.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 3, claudeValue.Usage.CacheReadInputTokens)
|
||||||
|
assert.Equal(t, 4, claudeValue.Usage.CacheCreationInputTokens)
|
||||||
|
assert.Equal(t, 5, claudeValue.Usage.OutputTokens)
|
||||||
|
|
||||||
|
chat.Usage.BillingUsage = dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 7,
|
||||||
|
ToolUsePromptTokenCount: 2,
|
||||||
|
CandidatesTokenCount: 5,
|
||||||
|
ThoughtsTokenCount: 3,
|
||||||
|
TotalTokenCount: 17,
|
||||||
|
})
|
||||||
|
|
||||||
|
toGemini, err := ConvertResponse(nil, nil, types.RelayFormatGemini, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
geminiValue := toGemini.Value.(*dto.GeminiChatResponse)
|
||||||
|
assert.Equal(t, 7, geminiValue.UsageMetadata.PromptTokenCount)
|
||||||
|
assert.Equal(t, 2, geminiValue.UsageMetadata.ToolUsePromptTokenCount)
|
||||||
|
assert.Equal(t, 5, geminiValue.UsageMetadata.CandidatesTokenCount)
|
||||||
|
assert.Equal(t, 3, geminiValue.UsageMetadata.ThoughtsTokenCount)
|
||||||
|
assert.Equal(t, 17, geminiValue.UsageMetadata.TotalTokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStreamResponseDirectConverters(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||||
|
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info.SendResponseCount = 1
|
||||||
|
finishReason := "stop"
|
||||||
|
result, err := ConvertStreamResponse(nil, info, types.RelayFormatClaude, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{
|
||||||
|
FinishReason: &finishReason,
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||||
|
Content: respPtr("hello"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Stream)
|
||||||
|
assert.Equal(t, ConverterOpenAIChatToClaudeMessages, result.Converter)
|
||||||
|
require.IsType(t, []*dto.ClaudeResponse{}, result.Value)
|
||||||
|
assert.Equal(t, 5, result.Usage.TotalTokens)
|
||||||
|
|
||||||
|
result, err = ConvertStreamResponse(nil, &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "gemini-test"}}, types.RelayFormatOpenAI, &dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{{Content: dto.GeminiChatContent{Parts: []dto.GeminiPart{{Text: "hello"}}}}},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 1,
|
||||||
|
CandidatesTokenCount: 2,
|
||||||
|
TotalTokenCount: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Stream)
|
||||||
|
assert.Equal(t, ConverterGeminiContentToOpenAIChat, result.Converter)
|
||||||
|
require.IsType(t, &dto.ChatCompletionsStreamResponse{}, result.Value)
|
||||||
|
assert.Equal(t, 3, result.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStreamResponseStatefulDirectConverters(t *testing.T) {
|
||||||
|
chatState, err := NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, ResponseStreamOptions{
|
||||||
|
ID: "resp_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
chatResults, err := ConvertStreamResponseChunk(nil, nil, chatState, &dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||||
|
{Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: respPtr("hello")}},
|
||||||
|
},
|
||||||
|
Usage: &dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, chatResults)
|
||||||
|
assert.Equal(t, ConverterOpenAIChatToOpenAIResponses, chatResults[0].Converter)
|
||||||
|
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIChatToOpenAIResponses, From: types.RelayFormatOpenAI, To: types.RelayFormatOpenAIResponses}}, chatResults[0].Steps)
|
||||||
|
assert.Equal(t, 5, chatState.Usage().TotalTokens)
|
||||||
|
|
||||||
|
finalResults, err := FinalizeStreamResponse(nil, nil, chatState)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, finalResults)
|
||||||
|
lastEvent, ok := finalResults[len(finalResults)-1].Value.(ChatToResponsesStreamEvent)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "response.completed", lastEvent.Type)
|
||||||
|
|
||||||
|
responsesState, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatOpenAI, ResponseStreamOptions{
|
||||||
|
ID: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
responsesResults, err := ConvertStreamResponseChunk(nil, nil, responsesState, &dto.ResponsesStreamResponse{
|
||||||
|
Type: "response.output_text.delta",
|
||||||
|
Delta: "hello",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, responsesResults)
|
||||||
|
assert.Equal(t, ConverterOpenAIResponsesToOpenAIChat, responsesResults[0].Converter)
|
||||||
|
assert.Equal(t, []ResponseStep{{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI}}, responsesResults[0].Steps)
|
||||||
|
require.IsType(t, dto.ChatCompletionsStreamResponse{}, responsesResults[len(responsesResults)-1].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertStreamResponseStatefulMultiHopResponsesToClaude(t *testing.T) {
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||||
|
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state, err := NewResponseStreamState(types.RelayFormatOpenAIResponses, types.RelayFormatClaude, ResponseStreamOptions{
|
||||||
|
ID: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results, err := ConvertStreamResponseChunk(nil, info, state, &dto.ResponsesStreamResponse{
|
||||||
|
Type: "response.output_text.delta",
|
||||||
|
Delta: "hello",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, results)
|
||||||
|
assert.Equal(t, requestConverterResponsesToClaude, results[0].Converter)
|
||||||
|
assert.Equal(t, []ResponseStep{
|
||||||
|
{Converter: ConverterOpenAIResponsesToOpenAIChat, From: types.RelayFormatOpenAIResponses, To: types.RelayFormatOpenAI},
|
||||||
|
{Converter: ConverterOpenAIChatToClaudeMessages, From: types.RelayFormatOpenAI, To: types.RelayFormatClaude},
|
||||||
|
}, results[0].Steps)
|
||||||
|
|
||||||
|
var sawTextDelta bool
|
||||||
|
for _, result := range results {
|
||||||
|
claudeResponse, ok := result.Value.(*dto.ClaudeResponse)
|
||||||
|
if !ok || claudeResponse == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if claudeResponse.Type == "content_block_delta" && claudeResponse.Delta != nil && claudeResponse.Delta.Text != nil && *claudeResponse.Delta.Text == "hello" {
|
||||||
|
sawTextDelta = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, sawTextDelta)
|
||||||
|
|
||||||
|
state.SetUsage(&dto.Usage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5})
|
||||||
|
_, err = FinalizeStreamResponse(nil, info, state)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 5, state.Usage().TotalTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseUsageMatrixChatAndResponsesDetails(t *testing.T) {
|
||||||
|
chat := textRegistryChatResponse()
|
||||||
|
chat.Usage = dto.Usage{
|
||||||
|
PromptTokens: 10,
|
||||||
|
CompletionTokens: 5,
|
||||||
|
TotalTokens: 20,
|
||||||
|
PromptTokensDetails: dto.InputTokenDetails{
|
||||||
|
CachedTokens: 3,
|
||||||
|
CachedCreationTokens: 2,
|
||||||
|
TextTokens: 4,
|
||||||
|
AudioTokens: 1,
|
||||||
|
ImageTokens: 5,
|
||||||
|
},
|
||||||
|
CompletionTokenDetails: dto.OutputTokenDetails{
|
||||||
|
ReasoningTokens: 2,
|
||||||
|
TextTokens: 2,
|
||||||
|
AudioTokens: 1,
|
||||||
|
ImageTokens: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := ConvertResponse(nil, nil, types.RelayFormatOpenAIResponses, chat)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 10, result.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 5, result.Usage.OutputTokens)
|
||||||
|
assert.Equal(t, 20, result.Usage.TotalTokens)
|
||||||
|
require.NotNil(t, result.Usage.InputTokensDetails)
|
||||||
|
assert.Equal(t, 3, result.Usage.InputTokensDetails.CachedTokens)
|
||||||
|
assert.Equal(t, 2, result.Usage.InputTokensDetails.CachedCreationTokens)
|
||||||
|
assert.Equal(t, 4, result.Usage.InputTokensDetails.TextTokens)
|
||||||
|
assert.Equal(t, 1, result.Usage.InputTokensDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 5, result.Usage.InputTokensDetails.ImageTokens)
|
||||||
|
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ReasoningTokens)
|
||||||
|
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.TextTokens)
|
||||||
|
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 2, result.Usage.CompletionTokenDetails.ImageTokens)
|
||||||
|
|
||||||
|
responses := &dto.OpenAIResponsesResponse{
|
||||||
|
ID: "resp_1",
|
||||||
|
Status: []byte(`"completed"`),
|
||||||
|
Model: "gpt-test",
|
||||||
|
Output: []dto.ResponsesOutput{},
|
||||||
|
CreatedAt: 123,
|
||||||
|
Usage: &dto.Usage{
|
||||||
|
InputTokens: 12,
|
||||||
|
OutputTokens: 8,
|
||||||
|
TotalTokens: 21,
|
||||||
|
InputTokensDetails: &dto.InputTokenDetails{
|
||||||
|
CachedTokens: 4,
|
||||||
|
CachedCreationTokens: 1,
|
||||||
|
TextTokens: 5,
|
||||||
|
AudioTokens: 2,
|
||||||
|
ImageTokens: 1,
|
||||||
|
},
|
||||||
|
CompletionTokenDetails: dto.OutputTokenDetails{
|
||||||
|
ReasoningTokens: 3,
|
||||||
|
TextTokens: 4,
|
||||||
|
AudioTokens: 1,
|
||||||
|
ImageTokens: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err = ConvertResponse(nil, nil, types.RelayFormatOpenAI, responses)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 12, result.Usage.PromptTokens)
|
||||||
|
assert.Equal(t, 8, result.Usage.CompletionTokens)
|
||||||
|
assert.Equal(t, 21, result.Usage.TotalTokens)
|
||||||
|
assert.Equal(t, 4, result.Usage.PromptTokensDetails.CachedTokens)
|
||||||
|
assert.Equal(t, 1, result.Usage.PromptTokensDetails.CachedCreationTokens)
|
||||||
|
assert.Equal(t, 5, result.Usage.PromptTokensDetails.TextTokens)
|
||||||
|
assert.Equal(t, 2, result.Usage.PromptTokensDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 1, result.Usage.PromptTokensDetails.ImageTokens)
|
||||||
|
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ReasoningTokens)
|
||||||
|
assert.Equal(t, 4, result.Usage.CompletionTokenDetails.TextTokens)
|
||||||
|
assert.Equal(t, 1, result.Usage.CompletionTokenDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 3, result.Usage.CompletionTokenDetails.ImageTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func textRegistryChatResponse() *dto.OpenAITextResponse {
|
||||||
|
msg := dto.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
msg.SetToolCalls([]dto.ToolCallRequest{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Type: "function",
|
||||||
|
Function: dto.FunctionRequest{
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: `{"q":"x"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return &dto.OpenAITextResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Created: 123,
|
||||||
|
Choices: []dto.OpenAITextResponseChoice{
|
||||||
|
{
|
||||||
|
Index: 0,
|
||||||
|
Message: msg,
|
||||||
|
FinishReason: "tool_calls",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: dto.Usage{PromptTokens: 4, CompletionTokens: 5, TotalTokens: 9},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textRegistryResponsesResponse() *dto.OpenAIResponsesResponse {
|
||||||
|
return &dto.OpenAIResponsesResponse{
|
||||||
|
ID: "resp_1",
|
||||||
|
CreatedAt: 123,
|
||||||
|
Model: "gpt-test",
|
||||||
|
Status: []byte(`"completed"`),
|
||||||
|
Output: []dto.ResponsesOutput{
|
||||||
|
{
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Content: []dto.ResponsesOutputContent{
|
||||||
|
{Type: "output_text", Text: "hello"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function_call",
|
||||||
|
ID: "call_1",
|
||||||
|
CallId: "call_1",
|
||||||
|
Name: "lookup",
|
||||||
|
Arguments: []byte(`{"q":"x"}`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: &dto.Usage{InputTokens: 4, OutputTokens: 7, TotalTokens: 11},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respPtr[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextConverterQuality string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TextConverterQualityGood TextConverterQuality = "good"
|
||||||
|
TextConverterQualityFair TextConverterQuality = "fair"
|
||||||
|
TextConverterQualityDiscouraged TextConverterQuality = "discouraged"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TextRequestSide struct {
|
||||||
|
Convert RequestConverterFunc
|
||||||
|
StepConverters []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextResponseSide struct {
|
||||||
|
Convert ResponseConverterFunc
|
||||||
|
ConvertStream ResponseStreamConverterFunc
|
||||||
|
NewStreamState ResponseStreamStateFactory
|
||||||
|
ConvertStreamChunk ResponseStreamChunkConverterFunc
|
||||||
|
FinalizeStream ResponseStreamFinalizerFunc
|
||||||
|
StepConverters []string
|
||||||
|
Aliases []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextConverterSpec struct {
|
||||||
|
ID string
|
||||||
|
From types.RelayFormat
|
||||||
|
To types.RelayFormat
|
||||||
|
Quality TextConverterQuality
|
||||||
|
Req TextRequestSide
|
||||||
|
Resp TextResponseSide
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
textConverterMu sync.RWMutex
|
||||||
|
textConverters = make(map[string]TextConverterSpec)
|
||||||
|
textConverterAliases = make(map[string]string)
|
||||||
|
)
|
||||||
|
|
||||||
|
var builtinTextConverters = []TextConverterSpec{
|
||||||
|
{
|
||||||
|
ID: ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
From: types.RelayFormatClaude,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertClaudeRequestToOpenAI,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
Convert: convertClaudeMessagesResponseToOAIChat,
|
||||||
|
ConvertStream: convertClaudeMessagesStreamResponseToOAIChat,
|
||||||
|
Aliases: []string{ResponseConverterClaudeMessagesToOAIChat},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ConverterOpenAIChatToClaudeMessages,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatClaude,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertOpenAIRequestToClaude,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
Convert: convertOAIChatResponseToClaudeMessages,
|
||||||
|
ConvertStream: convertOAIChatStreamResponseToClaudeMessages,
|
||||||
|
Aliases: []string{ResponseConverterOAIChatToClaudeMessages},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ConverterGeminiContentToOpenAIChat,
|
||||||
|
From: types.RelayFormatGemini,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertGeminiRequestToOpenAI,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
Convert: convertGeminiChatResponseToOAIChat,
|
||||||
|
ConvertStream: convertGeminiChatStreamResponseToOAIChat,
|
||||||
|
Aliases: []string{ResponseConverterGeminiChatToOAIChat},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ConverterOpenAIChatToGeminiContent,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatGemini,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertOpenAIRequestToGemini,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
Convert: convertOAIChatResponseToGeminiChat,
|
||||||
|
ConvertStream: convertOAIChatStreamResponseToGeminiChat,
|
||||||
|
Aliases: []string{ResponseConverterOAIChatToGeminiChat},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
From: types.RelayFormatOpenAI,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
Quality: TextConverterQualityGood,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertChatRequestToResponses,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
Convert: convertOAIChatResponseToOAIResponses,
|
||||||
|
NewStreamState: newOAIChatToOAIResponsesStreamState,
|
||||||
|
ConvertStreamChunk: convertOAIChatStreamResponseToOAIResponses,
|
||||||
|
FinalizeStream: finalizeOAIChatStreamResponseToOAIResponses,
|
||||||
|
Aliases: []string{ResponseConverterOAIChatToOAIResponses},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
Quality: TextConverterQualityGood,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertResponsesRequestToChat,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
Convert: convertOAIResponsesResponseToOAIChat,
|
||||||
|
NewStreamState: newOAIResponsesToOAIChatStreamState,
|
||||||
|
ConvertStreamChunk: convertOAIResponsesStreamResponseToOAIChat,
|
||||||
|
FinalizeStream: finalizeOAIResponsesStreamResponseToOAIChat,
|
||||||
|
Aliases: []string{ResponseConverterOAIResponsesToOAIChat},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: requestConverterClaudeToGemini,
|
||||||
|
From: types.RelayFormatClaude,
|
||||||
|
To: types.RelayFormatGemini,
|
||||||
|
Quality: TextConverterQualityDiscouraged,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
Aliases: []string{responseConverterClaudeToGemini},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: requestConverterClaudeToResponses,
|
||||||
|
From: types.RelayFormatClaude,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
Aliases: []string{responseConverterClaudeToResponses},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: requestConverterGeminiToClaude,
|
||||||
|
From: types.RelayFormatGemini,
|
||||||
|
To: types.RelayFormatClaude,
|
||||||
|
Quality: TextConverterQualityDiscouraged,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
Aliases: []string{responseConverterGeminiToClaude},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: requestConverterGeminiToResponses,
|
||||||
|
From: types.RelayFormatGemini,
|
||||||
|
To: types.RelayFormatOpenAIResponses,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
Aliases: []string{responseConverterGeminiToResponses},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: requestConverterResponsesToClaude,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatClaude,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertOpenAIResponsesRequestToClaudeMessages,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
Aliases: []string{responseConverterResponsesToClaude},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: ConverterOpenAIResponsesToGemini,
|
||||||
|
From: types.RelayFormatOpenAIResponses,
|
||||||
|
To: types.RelayFormatGemini,
|
||||||
|
Quality: TextConverterQualityFair,
|
||||||
|
Req: TextRequestSide{
|
||||||
|
Convert: convertOpenAIResponsesRequestToGeminiChat,
|
||||||
|
},
|
||||||
|
Resp: TextResponseSide{
|
||||||
|
StepConverters: []string{
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
Aliases: []string{responseConverterResponsesToGemini},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for _, spec := range builtinTextConverters {
|
||||||
|
registerBuiltinTextConverter(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LookupTextConverter(converter string) (TextConverterSpec, bool) {
|
||||||
|
textConverterMu.RLock()
|
||||||
|
defer textConverterMu.RUnlock()
|
||||||
|
|
||||||
|
converterID := resolveTextConverterID(converter)
|
||||||
|
spec, ok := textConverters[converterID]
|
||||||
|
if !ok {
|
||||||
|
return TextConverterSpec{}, false
|
||||||
|
}
|
||||||
|
return cloneTextConverterSpec(spec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerBuiltinTextConverter(spec TextConverterSpec) {
|
||||||
|
spec.ID = strings.TrimSpace(spec.ID)
|
||||||
|
if spec.ID == "" {
|
||||||
|
panic("text converter ID is required")
|
||||||
|
}
|
||||||
|
if spec.From == "" || spec.To == "" {
|
||||||
|
panic(fmt.Sprintf("text converter %q must declare from and to formats", spec.ID))
|
||||||
|
}
|
||||||
|
if spec.Quality == "" {
|
||||||
|
panic(fmt.Sprintf("text converter %q must declare quality", spec.ID))
|
||||||
|
}
|
||||||
|
if !textRequestSideConfigured(spec.Req) {
|
||||||
|
panic(fmt.Sprintf("text converter %q must declare request conversion", spec.ID))
|
||||||
|
}
|
||||||
|
if !textResponseSideConfigured(spec.Resp) {
|
||||||
|
panic(fmt.Sprintf("text converter %q must declare response conversion", spec.ID))
|
||||||
|
}
|
||||||
|
if _, exists := textConverters[spec.ID]; exists {
|
||||||
|
panic(fmt.Sprintf("text converter %q is already registered", spec.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBuiltinRequestConverter(RequestConverterSpec{
|
||||||
|
ID: spec.ID,
|
||||||
|
From: spec.From,
|
||||||
|
To: spec.To,
|
||||||
|
Quality: RequestConverterQuality(spec.Quality),
|
||||||
|
Convert: spec.Req.Convert,
|
||||||
|
StepConverters: cloneTextConverterStrings(spec.Req.StepConverters),
|
||||||
|
})
|
||||||
|
registerBuiltinResponseConverter(ResponseConverterSpec{
|
||||||
|
ID: spec.ID,
|
||||||
|
From: spec.From,
|
||||||
|
To: spec.To,
|
||||||
|
Quality: ResponseConverterQuality(spec.Quality),
|
||||||
|
Convert: spec.Resp.Convert,
|
||||||
|
ConvertStream: spec.Resp.ConvertStream,
|
||||||
|
NewStreamState: spec.Resp.NewStreamState,
|
||||||
|
ConvertStreamChunk: spec.Resp.ConvertStreamChunk,
|
||||||
|
FinalizeStream: spec.Resp.FinalizeStream,
|
||||||
|
StepConverters: cloneTextConverterStrings(spec.Resp.StepConverters),
|
||||||
|
})
|
||||||
|
|
||||||
|
textConverters[spec.ID] = cloneTextConverterSpec(spec)
|
||||||
|
for _, alias := range spec.Resp.Aliases {
|
||||||
|
registerResponseConverterAlias(alias, spec.ID)
|
||||||
|
registerTextConverterAlias(alias, spec.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerTextConverterAlias(alias string, converter string) {
|
||||||
|
alias = strings.TrimSpace(alias)
|
||||||
|
converter = strings.TrimSpace(converter)
|
||||||
|
if alias == "" {
|
||||||
|
panic("text converter alias is required")
|
||||||
|
}
|
||||||
|
if converter == "" {
|
||||||
|
panic(fmt.Sprintf("text converter alias %q target is required", alias))
|
||||||
|
}
|
||||||
|
if alias == converter {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := textConverters[alias]; exists {
|
||||||
|
panic(fmt.Sprintf("text converter alias %q conflicts with registered converter", alias))
|
||||||
|
}
|
||||||
|
if _, exists := textConverters[converter]; !exists {
|
||||||
|
panic(fmt.Sprintf("text converter alias %q references unknown converter %q", alias, converter))
|
||||||
|
}
|
||||||
|
if existing, exists := textConverterAliases[alias]; exists && existing != converter {
|
||||||
|
panic(fmt.Sprintf("text converter alias %q is already registered for %q", alias, existing))
|
||||||
|
}
|
||||||
|
textConverterAliases[alias] = converter
|
||||||
|
}
|
||||||
|
|
||||||
|
func textRequestSideConfigured(side TextRequestSide) bool {
|
||||||
|
return side.Convert != nil || len(side.StepConverters) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func textResponseSideConfigured(side TextResponseSide) bool {
|
||||||
|
return side.Convert != nil ||
|
||||||
|
side.ConvertStream != nil ||
|
||||||
|
side.NewStreamState != nil ||
|
||||||
|
side.ConvertStreamChunk != nil ||
|
||||||
|
side.FinalizeStream != nil ||
|
||||||
|
len(side.StepConverters) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTextConverterID(converter string) string {
|
||||||
|
converter = strings.TrimSpace(converter)
|
||||||
|
if canonical, ok := textConverterAliases[converter]; ok {
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
|
return converter
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTextConverterSpec(spec TextConverterSpec) TextConverterSpec {
|
||||||
|
spec.Req.StepConverters = cloneTextConverterStrings(spec.Req.StepConverters)
|
||||||
|
spec.Resp.StepConverters = cloneTextConverterStrings(spec.Resp.StepConverters)
|
||||||
|
spec.Resp.Aliases = cloneTextConverterStrings(spec.Resp.Aliases)
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTextConverterStrings(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string{}, values...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package relayconvert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLookupBuiltinTextConverters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
id string
|
||||||
|
from types.RelayFormat
|
||||||
|
to types.RelayFormat
|
||||||
|
quality TextConverterQuality
|
||||||
|
reqSteps []string
|
||||||
|
respSteps []string
|
||||||
|
reqDirect bool
|
||||||
|
respDirect bool
|
||||||
|
respAlias string
|
||||||
|
streamDirect bool
|
||||||
|
}{
|
||||||
|
{id: ConverterClaudeMessagesToOpenAIChat, from: types.RelayFormatClaude, to: types.RelayFormatOpenAI, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterClaudeMessagesToOAIChat},
|
||||||
|
{id: ConverterOpenAIChatToClaudeMessages, from: types.RelayFormatOpenAI, to: types.RelayFormatClaude, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToClaudeMessages},
|
||||||
|
{id: ConverterGeminiContentToOpenAIChat, from: types.RelayFormatGemini, to: types.RelayFormatOpenAI, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterGeminiChatToOAIChat},
|
||||||
|
{id: ConverterOpenAIChatToGeminiContent, from: types.RelayFormatOpenAI, to: types.RelayFormatGemini, quality: TextConverterQualityFair, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToGeminiChat},
|
||||||
|
{id: ConverterOpenAIChatToOpenAIResponses, from: types.RelayFormatOpenAI, to: types.RelayFormatOpenAIResponses, quality: TextConverterQualityGood, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIChatToOAIResponses, streamDirect: true},
|
||||||
|
{id: ConverterOpenAIResponsesToOpenAIChat, from: types.RelayFormatOpenAIResponses, to: types.RelayFormatOpenAI, quality: TextConverterQualityGood, reqDirect: true, respDirect: true, respAlias: ResponseConverterOAIResponsesToOAIChat, streamDirect: true},
|
||||||
|
{
|
||||||
|
id: requestConverterClaudeToGemini,
|
||||||
|
from: types.RelayFormatClaude,
|
||||||
|
to: types.RelayFormatGemini,
|
||||||
|
quality: TextConverterQualityDiscouraged,
|
||||||
|
reqSteps: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
respSteps: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
respAlias: responseConverterClaudeToGemini,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: requestConverterClaudeToResponses,
|
||||||
|
from: types.RelayFormatClaude,
|
||||||
|
to: types.RelayFormatOpenAIResponses,
|
||||||
|
quality: TextConverterQualityFair,
|
||||||
|
reqSteps: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
respSteps: []string{
|
||||||
|
ConverterClaudeMessagesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
respAlias: responseConverterClaudeToResponses,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: requestConverterGeminiToClaude,
|
||||||
|
from: types.RelayFormatGemini,
|
||||||
|
to: types.RelayFormatClaude,
|
||||||
|
quality: TextConverterQualityDiscouraged,
|
||||||
|
reqSteps: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
respSteps: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
respAlias: responseConverterGeminiToClaude,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: requestConverterGeminiToResponses,
|
||||||
|
from: types.RelayFormatGemini,
|
||||||
|
to: types.RelayFormatOpenAIResponses,
|
||||||
|
quality: TextConverterQualityFair,
|
||||||
|
reqSteps: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
respSteps: []string{
|
||||||
|
ConverterGeminiContentToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToOpenAIResponses,
|
||||||
|
},
|
||||||
|
respAlias: responseConverterGeminiToResponses,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: requestConverterResponsesToClaude,
|
||||||
|
from: types.RelayFormatOpenAIResponses,
|
||||||
|
to: types.RelayFormatClaude,
|
||||||
|
quality: TextConverterQualityFair,
|
||||||
|
reqDirect: true,
|
||||||
|
respSteps: []string{
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToClaudeMessages,
|
||||||
|
},
|
||||||
|
respAlias: responseConverterResponsesToClaude,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: ConverterOpenAIResponsesToGemini,
|
||||||
|
from: types.RelayFormatOpenAIResponses,
|
||||||
|
to: types.RelayFormatGemini,
|
||||||
|
quality: TextConverterQualityFair,
|
||||||
|
reqDirect: true,
|
||||||
|
respSteps: []string{
|
||||||
|
ConverterOpenAIResponsesToOpenAIChat,
|
||||||
|
ConverterOpenAIChatToGeminiContent,
|
||||||
|
},
|
||||||
|
respAlias: responseConverterResponsesToGemini,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, textConverters, len(tests))
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.id, func(t *testing.T) {
|
||||||
|
spec, ok := LookupTextConverter(tt.id)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, tt.id, spec.ID)
|
||||||
|
assert.Equal(t, tt.from, spec.From)
|
||||||
|
assert.Equal(t, tt.to, spec.To)
|
||||||
|
assert.Equal(t, tt.quality, spec.Quality)
|
||||||
|
assert.Equal(t, tt.reqSteps, spec.Req.StepConverters)
|
||||||
|
assert.Equal(t, tt.respSteps, spec.Resp.StepConverters)
|
||||||
|
assert.Equal(t, tt.reqDirect, spec.Req.Convert != nil)
|
||||||
|
assert.Equal(t, tt.respDirect, spec.Resp.Convert != nil)
|
||||||
|
assert.Equal(t, tt.streamDirect, spec.Resp.NewStreamState != nil && spec.Resp.ConvertStreamChunk != nil && spec.Resp.FinalizeStream != nil)
|
||||||
|
|
||||||
|
aliasSpec, ok := LookupTextConverter(tt.respAlias)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, tt.id, aliasSpec.ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
|
"github.com/QuantumNous/new-api/service/relayconvert"
|
||||||
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
relayconvert.SetMediaResolver(relayconvert.MediaResolver{
|
||||||
|
GetBase64Data: GetBase64Data,
|
||||||
|
DecodeBase64FileData: DecodeBase64FileData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertRequest(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, request any) (*relayconvert.RequestResult, error) {
|
||||||
|
return relayconvert.ConvertRequest(c, info, target, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertRequestByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (*relayconvert.RequestResult, error) {
|
||||||
|
return relayconvert.ConvertRequestByID(c, info, converter, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConvertRequestVia(c *gin.Context, info *relaycommon.RelayInfo, request any, path ...types.RelayFormat) (*relayconvert.RequestResult, error) {
|
||||||
|
return relayconvert.ConvertRequestVia(c, info, request, path...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, &claudeRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return openAIRequest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
|
||||||
|
result, err := ConvertRequest(nil, info, types.RelayFormatOpenAI, geminiRequest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value)
|
||||||
|
}
|
||||||
|
return openAIRequest, nil
|
||||||
|
}
|
||||||
+10
-5
@@ -175,6 +175,9 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// calculateTextQuotaSummary expects a usage already remapped by
|
||||||
|
// effectiveBillingUsage; PostTextConsumeQuota performs that remap once and shares
|
||||||
|
// the result with tiered billing, affinity observation and logging.
|
||||||
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
|
func calculateTextQuotaSummary(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) textQuotaSummary {
|
||||||
summary := textQuotaSummary{
|
summary := textQuotaSummary{
|
||||||
ModelName: relayInfo.OriginModelName,
|
ModelName: relayInfo.OriginModelName,
|
||||||
@@ -335,15 +338,16 @@ func usageSemanticFromUsage(relayInfo *relaycommon.RelayInfo, usage *dto.Usage)
|
|||||||
|
|
||||||
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
|
func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent []string) {
|
||||||
originUsage := usage
|
originUsage := usage
|
||||||
|
billingUsage := effectiveBillingUsage(usage)
|
||||||
if usage == nil {
|
if usage == nil {
|
||||||
extraContent = append(extraContent, "上游无计费信息")
|
extraContent = append(extraContent, "上游无计费信息")
|
||||||
}
|
}
|
||||||
if originUsage != nil {
|
if originUsage != nil {
|
||||||
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
|
ObserveChannelAffinityUsageCacheByRelayFormat(ctx, billingUsage, relayInfo.GetFinalRequestRelayFormat())
|
||||||
}
|
}
|
||||||
|
|
||||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||||||
summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
|
summary := calculateTextQuotaSummary(ctx, relayInfo, billingUsage)
|
||||||
|
|
||||||
var tieredResult *billingexpr.TieredResult
|
var tieredResult *billingexpr.TieredResult
|
||||||
tieredBillingApplied := false
|
tieredBillingApplied := false
|
||||||
@@ -352,7 +356,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
|||||||
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
|
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
|
||||||
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
|
tieredUsedVars = billingexpr.UsedVars(snap.ExprString)
|
||||||
}
|
}
|
||||||
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(usage, summary.IsClaudeUsageSemantic, tieredUsedVars))
|
tieredOk, tieredQuota, tieredRes := TryTieredSettle(relayInfo, BuildTieredTokenParams(billingUsage, summary.IsClaudeUsageSemantic, tieredUsedVars))
|
||||||
if tieredOk {
|
if tieredOk {
|
||||||
tieredBillingApplied = true
|
tieredBillingApplied = true
|
||||||
tieredResult = tieredRes
|
tieredResult = tieredRes
|
||||||
@@ -412,6 +416,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
|||||||
} else {
|
} else {
|
||||||
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
|
||||||
}
|
}
|
||||||
|
appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage)
|
||||||
if adminRejectReason != "" {
|
if adminRejectReason != "" {
|
||||||
other["reject_reason"] = adminRejectReason
|
other["reject_reason"] = adminRejectReason
|
||||||
}
|
}
|
||||||
@@ -462,12 +467,12 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
|
|||||||
// to cache_creation_tokens.
|
// to cache_creation_tokens.
|
||||||
other["cache_write_tokens"] = cacheWriteTokens
|
other["cache_write_tokens"] = cacheWriteTokens
|
||||||
}
|
}
|
||||||
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && usage != nil && usage.UsageSource != "" && usage.InputTokens > 0 {
|
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 {
|
||||||
// input_tokens_total: explicit normalized total input used by the usage log UI.
|
// input_tokens_total: explicit normalized total input used by the usage log UI.
|
||||||
// Only write this field when upstream/current conversion has already provided a
|
// Only write this field when upstream/current conversion has already provided a
|
||||||
// reliable total input value and tagged the usage source. Do not infer it from
|
// reliable total input value and tagged the usage source. Do not infer it from
|
||||||
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
|
// prompt/cache fields here, otherwise old upstream payloads may be double-counted.
|
||||||
other["input_tokens_total"] = usage.InputTokens
|
other["input_tokens_total"] = billingUsage.InputTokens
|
||||||
}
|
}
|
||||||
if tieredBillingApplied {
|
if tieredBillingApplied {
|
||||||
InjectTieredBillingInfo(other, relayInfo, tieredResult)
|
InjectTieredBillingInfo(other, relayInfo, tieredResult)
|
||||||
|
|||||||
@@ -150,6 +150,172 @@ func TestCalculateTextQuotaSummaryUsesAnthropicUsageSemanticFromUpstreamUsage(t
|
|||||||
require.Equal(t, 1488, summary.Quota)
|
require.Equal(t, 1488, summary.Quota)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextQuotaSummaryUsesClaudeBillingUsageBeforeTopLevelUsage(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
relayInfo := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
OriginModelName: "claude-3-7-sonnet",
|
||||||
|
PriceData: types.PriceData{
|
||||||
|
ModelRatio: 1,
|
||||||
|
CompletionRatio: 2,
|
||||||
|
CacheRatio: 0.1,
|
||||||
|
CacheCreationRatio: 1.25,
|
||||||
|
CacheCreation5mRatio: 1.25,
|
||||||
|
CacheCreation1hRatio: 2,
|
||||||
|
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||||
|
},
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: 999,
|
||||||
|
CompletionTokens: 999,
|
||||||
|
TotalTokens: 1998,
|
||||||
|
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
|
||||||
|
InputTokens: 70,
|
||||||
|
CacheReadInputTokens: 30,
|
||||||
|
CacheCreationInputTokens: 20,
|
||||||
|
OutputTokens: 7,
|
||||||
|
CacheCreation: &dto.ClaudeCacheCreationUsage{
|
||||||
|
Ephemeral5mInputTokens: 12,
|
||||||
|
Ephemeral1hInputTokens: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage))
|
||||||
|
|
||||||
|
require.True(t, summary.IsClaudeUsageSemantic)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticAnthropic, summary.UsageSemantic)
|
||||||
|
require.Equal(t, 70, summary.PromptTokens)
|
||||||
|
require.Equal(t, 7, summary.CompletionTokens)
|
||||||
|
require.Equal(t, 30, summary.CacheTokens)
|
||||||
|
require.Equal(t, 20, summary.CacheCreationTokens)
|
||||||
|
require.Equal(t, 12, summary.CacheCreationTokens5m)
|
||||||
|
require.Equal(t, 8, summary.CacheCreationTokens1h)
|
||||||
|
require.Equal(t, 118, summary.Quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextQuotaSummaryUsesGeminiBillingUsageBeforeTopLevelUsage(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
relayInfo := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatOpenAI,
|
||||||
|
OriginModelName: "gemini-2.5-flash",
|
||||||
|
PriceData: types.PriceData{
|
||||||
|
ModelRatio: 1,
|
||||||
|
CompletionRatio: 2,
|
||||||
|
CacheRatio: 0.1,
|
||||||
|
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||||
|
},
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: 999,
|
||||||
|
CompletionTokens: 999,
|
||||||
|
TotalTokens: 1998,
|
||||||
|
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 100,
|
||||||
|
ToolUsePromptTokenCount: 5,
|
||||||
|
CandidatesTokenCount: 20,
|
||||||
|
ThoughtsTokenCount: 3,
|
||||||
|
TotalTokenCount: 128,
|
||||||
|
CachedContentTokenCount: 7,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage))
|
||||||
|
|
||||||
|
require.False(t, summary.IsClaudeUsageSemantic)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticGemini, summary.UsageSemantic)
|
||||||
|
require.Equal(t, 105, summary.PromptTokens)
|
||||||
|
require.Equal(t, 23, summary.CompletionTokens)
|
||||||
|
require.Equal(t, 7, summary.CacheTokens)
|
||||||
|
require.Equal(t, 128, summary.TotalTokens)
|
||||||
|
require.Equal(t, 145, summary.Quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateTextQuotaSummaryUsesOpenAIBillingUsageBeforeTopLevelUsage(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(w)
|
||||||
|
|
||||||
|
relayInfo := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatClaude,
|
||||||
|
OriginModelName: "gpt-4o",
|
||||||
|
PriceData: types.PriceData{
|
||||||
|
ModelRatio: 1,
|
||||||
|
CompletionRatio: 2,
|
||||||
|
GroupRatioInfo: types.GroupRatioInfo{GroupRatio: 1},
|
||||||
|
},
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := &dto.Usage{
|
||||||
|
PromptTokens: 999,
|
||||||
|
CompletionTokens: 999,
|
||||||
|
TotalTokens: 1998,
|
||||||
|
BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{
|
||||||
|
PromptTokens: 80,
|
||||||
|
CompletionTokens: 9,
|
||||||
|
TotalTokens: 89,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := calculateTextQuotaSummary(ctx, relayInfo, effectiveBillingUsage(usage))
|
||||||
|
|
||||||
|
require.False(t, summary.IsClaudeUsageSemantic)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticOpenAI, summary.UsageSemantic)
|
||||||
|
require.Equal(t, 80, summary.PromptTokens)
|
||||||
|
require.Equal(t, 9, summary.CompletionTokens)
|
||||||
|
require.Equal(t, 89, summary.TotalTokens)
|
||||||
|
require.Equal(t, 98, summary.Quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsageBillingPathForLog(t *testing.T) {
|
||||||
|
require.Equal(t, usageBillingPathLocal, usageBillingPathForLog(true, &dto.Usage{
|
||||||
|
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||||
|
}))
|
||||||
|
require.Equal(t, usageBillingPathUpstream, usageBillingPathForLog(false, &dto.Usage{}))
|
||||||
|
require.Equal(t, usageBillingPathOpenAI, usageBillingPathForLog(false, &dto.Usage{
|
||||||
|
BillingUsage: dto.NewOpenAIChatBillingUsage(&dto.Usage{PromptTokens: 1}),
|
||||||
|
}))
|
||||||
|
require.Equal(t, usageBillingPathAnthropic, usageBillingPathForLog(false, &dto.Usage{
|
||||||
|
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||||
|
}))
|
||||||
|
require.Equal(t, usageBillingPathGemini, usageBillingPathForLog(false, &dto.Usage{
|
||||||
|
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{PromptTokenCount: 1}),
|
||||||
|
}))
|
||||||
|
require.Equal(t, usageBillingPathGeminiEstimated, usageBillingPathForLog(false, &dto.Usage{
|
||||||
|
BillingUsage: dto.NewEstimatedGeminiChatBillingUsage(&dto.Usage{PromptTokens: 1}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) {
|
||||||
|
other := map[string]interface{}{
|
||||||
|
"admin_info": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
appendUsageBillingPathForLog(other, false, &dto.Usage{
|
||||||
|
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
|
||||||
|
})
|
||||||
|
|
||||||
|
adminInfo, ok := other["admin_info"].(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"])
|
||||||
|
|
||||||
|
other = map[string]interface{}{}
|
||||||
|
appendUsageBillingPathForLog(other, true, nil)
|
||||||
|
adminInfo, ok = other["admin_info"].(map[string]interface{})
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestCacheWriteTokensTotal(t *testing.T) {
|
func TestCacheWriteTokensTotal(t *testing.T) {
|
||||||
t.Run("split cache creation", func(t *testing.T) {
|
t.Run("split cache creation", func(t *testing.T) {
|
||||||
summary := textQuotaSummary{
|
summary := textQuotaSummary{
|
||||||
|
|||||||
+658
-109
@@ -16,15 +16,35 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { ArrowRight, Check, Plus, Shuffle, Trash2 } from 'lucide-react'
|
import {
|
||||||
|
ArrowDown,
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
Check,
|
||||||
|
Info,
|
||||||
|
Plus,
|
||||||
|
Shuffle,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import { type ReactNode, useMemo, useRef, useState } from 'react'
|
import { type ReactNode, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Dialog } from '@/components/dialog'
|
import { Dialog } from '@/components/dialog'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -53,13 +73,17 @@ import {
|
|||||||
createAdvancedCustomConfig,
|
createAdvancedCustomConfig,
|
||||||
createAdvancedCustomRoute,
|
createAdvancedCustomRoute,
|
||||||
getAdvancedCustomAuthMode,
|
getAdvancedCustomAuthMode,
|
||||||
|
getAdvancedCustomConverterDefaults,
|
||||||
getAdvancedCustomConverterOptions,
|
getAdvancedCustomConverterOptions,
|
||||||
getAdvancedCustomIncomingPathLabel,
|
getAdvancedCustomIncomingPathLabel,
|
||||||
|
getAdvancedCustomModelRuleKind,
|
||||||
|
getAdvancedCustomRegexModelPattern,
|
||||||
getAdvancedCustomTemplateConfig,
|
getAdvancedCustomTemplateConfig,
|
||||||
getAdvancedCustomUpstreamPathPlaceholder,
|
getAdvancedCustomUpstreamPathPlaceholder,
|
||||||
getDefaultAdvancedCustomIncomingPath,
|
getDefaultAdvancedCustomIncomingPath,
|
||||||
isAdvancedCustomIncomingPathAllowed,
|
isAdvancedCustomIncomingPathAllowed,
|
||||||
normalizeAdvancedCustomConfig,
|
normalizeAdvancedCustomConfig,
|
||||||
|
parseAdvancedCustomRouteModels,
|
||||||
parseAdvancedCustomConfig,
|
parseAdvancedCustomConfig,
|
||||||
stringifyAdvancedCustomConfig,
|
stringifyAdvancedCustomConfig,
|
||||||
validateAdvancedCustomConfig,
|
validateAdvancedCustomConfig,
|
||||||
@@ -84,9 +108,23 @@ const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]'
|
|||||||
const longSelectItemClass =
|
const longSelectItemClass =
|
||||||
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
|
'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal'
|
||||||
const routeEditorGridClassName =
|
const routeEditorGridClassName =
|
||||||
'lg:grid-cols-[7rem_minmax(0,1.45fr)_minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.85fr)_2rem]'
|
'lg:grid-cols-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]'
|
||||||
const upstreamPathDescriptionKey =
|
const upstreamPathDescriptionKey =
|
||||||
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
|
'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.'
|
||||||
|
const catchAllOrderErrorMessage =
|
||||||
|
'Catch-all route must be last for the same incoming path'
|
||||||
|
const emptyAdvancedRoutes: AdvancedCustomRoute[] = []
|
||||||
|
|
||||||
|
type AdvancedCustomRouteRow = {
|
||||||
|
route: AdvancedCustomRoute
|
||||||
|
routeKey: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvancedCustomRouteGroup = {
|
||||||
|
incomingPath: string
|
||||||
|
routeRows: AdvancedCustomRouteRow[]
|
||||||
|
}
|
||||||
|
|
||||||
function getOptionLabel(
|
function getOptionLabel(
|
||||||
options: ReadonlyArray<{ value: string; label: string }>,
|
options: ReadonlyArray<{ value: string; label: string }>,
|
||||||
@@ -95,6 +133,34 @@ function getOptionLabel(
|
|||||||
return options.find((option) => option.value === value)?.label || value
|
return options.find((option) => option.value === value)?.label || value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRouteIncomingPath(route: AdvancedCustomRoute): string {
|
||||||
|
return (route.incoming_path || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCatchAllRoute(route: AdvancedCustomRoute): boolean {
|
||||||
|
return !route.models || route.models.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouteGroups(
|
||||||
|
routeRows: AdvancedCustomRouteRow[]
|
||||||
|
): AdvancedCustomRouteGroup[] {
|
||||||
|
const groups: AdvancedCustomRouteGroup[] = []
|
||||||
|
const groupByPath = new Map<string, AdvancedCustomRouteGroup>()
|
||||||
|
|
||||||
|
for (const routeRow of routeRows) {
|
||||||
|
const incomingPath = getRouteIncomingPath(routeRow.route)
|
||||||
|
let group = groupByPath.get(incomingPath)
|
||||||
|
if (!group) {
|
||||||
|
group = { incomingPath, routeRows: [] }
|
||||||
|
groupByPath.set(incomingPath, group)
|
||||||
|
groups.push(group)
|
||||||
|
}
|
||||||
|
group.routeRows.push(routeRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
export function AdvancedCustomEditorDialog({
|
export function AdvancedCustomEditorDialog({
|
||||||
open,
|
open,
|
||||||
value,
|
value,
|
||||||
@@ -133,20 +199,28 @@ export function AdvancedCustomEditorDialog({
|
|||||||
() => normalizeAdvancedCustomConfig(config),
|
() => normalizeAdvancedCustomConfig(config),
|
||||||
[config]
|
[config]
|
||||||
)
|
)
|
||||||
const routes = normalizedConfig.advanced_routes || []
|
const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes
|
||||||
const routeRows = routes.map((route, index) => ({
|
const routeRows = useMemo(
|
||||||
route,
|
() =>
|
||||||
routeKey:
|
routes.map((route, index) => ({
|
||||||
routeKeys.at(index) ||
|
route,
|
||||||
route.incoming_path ||
|
index,
|
||||||
route.upstream_path ||
|
routeKey:
|
||||||
route.converter ||
|
routeKeys.at(index) ||
|
||||||
'advanced-custom-route',
|
route.incoming_path ||
|
||||||
}))
|
route.upstream_path ||
|
||||||
|
route.converter ||
|
||||||
|
'advanced-custom-route',
|
||||||
|
})),
|
||||||
|
[routeKeys, routes]
|
||||||
|
)
|
||||||
|
const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows])
|
||||||
const validationError = useMemo(
|
const validationError = useMemo(
|
||||||
() => validateAdvancedCustomConfig(normalizedConfig),
|
() => validateAdvancedCustomConfig(normalizedConfig),
|
||||||
[normalizedConfig]
|
[normalizedConfig]
|
||||||
)
|
)
|
||||||
|
const canFixCatchAllOrder =
|
||||||
|
validationError?.message === catchAllOrderErrorMessage
|
||||||
|
|
||||||
const createRouteKey = () => {
|
const createRouteKey = () => {
|
||||||
routeKeyCounterRef.current += 1
|
routeKeyCounterRef.current += 1
|
||||||
@@ -165,6 +239,17 @@ export function AdvancedCustomEditorDialog({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replaceRoutes = (
|
||||||
|
nextRoutes: AdvancedCustomRoute[],
|
||||||
|
nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
||||||
|
) => {
|
||||||
|
setConfig((current) => {
|
||||||
|
const next = normalizeAdvancedCustomConfig(current)
|
||||||
|
return { ...next, advanced_routes: nextRoutes }
|
||||||
|
})
|
||||||
|
setRouteKeys(nextRouteKeys)
|
||||||
|
}
|
||||||
|
|
||||||
const addRoute = () => {
|
const addRoute = () => {
|
||||||
setConfig((current) => {
|
setConfig((current) => {
|
||||||
const next = normalizeAdvancedCustomConfig(current)
|
const next = normalizeAdvancedCustomConfig(current)
|
||||||
@@ -179,6 +264,25 @@ export function AdvancedCustomEditorDialog({
|
|||||||
setRouteKeys((current) => [...current, createRouteKey()])
|
setRouteKeys((current) => [...current, createRouteKey()])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addRouteForIncomingPath = (incomingPath: string) => {
|
||||||
|
const resolvedIncomingPath = incomingPath || '/v1/chat/completions'
|
||||||
|
setConfig((current) => {
|
||||||
|
const next = normalizeAdvancedCustomConfig(current)
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
advanced_routes: [
|
||||||
|
...(next.advanced_routes || []),
|
||||||
|
{
|
||||||
|
...createAdvancedCustomRoute(),
|
||||||
|
incoming_path: resolvedIncomingPath,
|
||||||
|
upstream_path: resolvedIncomingPath,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setRouteKeys((current) => [...current, createRouteKey()])
|
||||||
|
}
|
||||||
|
|
||||||
const removeRoute = (index: number) => {
|
const removeRoute = (index: number) => {
|
||||||
setConfig((current) => {
|
setConfig((current) => {
|
||||||
const next = normalizeAdvancedCustomConfig(current)
|
const next = normalizeAdvancedCustomConfig(current)
|
||||||
@@ -194,6 +298,105 @@ export function AdvancedCustomEditorDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateGroupIncomingPath = (
|
||||||
|
group: AdvancedCustomRouteGroup,
|
||||||
|
nextIncomingPath: string | null
|
||||||
|
) => {
|
||||||
|
const resolvedIncomingPath = nextIncomingPath || '/v1/chat/completions'
|
||||||
|
const groupRouteIndexes = new Set(
|
||||||
|
group.routeRows.map((routeRow) => routeRow.index)
|
||||||
|
)
|
||||||
|
const nextRoutes = routes.map((route, routeIndex) => {
|
||||||
|
if (!groupRouteIndexes.has(routeIndex)) return route
|
||||||
|
const converter = route.converter || 'none'
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
incoming_path: resolvedIncomingPath,
|
||||||
|
converter: isAdvancedCustomIncomingPathAllowed(
|
||||||
|
resolvedIncomingPath,
|
||||||
|
converter
|
||||||
|
)
|
||||||
|
? converter
|
||||||
|
: 'none',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
replaceRoutes(nextRoutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
const swapRoutes = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (fromIndex === toIndex) return
|
||||||
|
const nextRoutes = [...routes]
|
||||||
|
const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
||||||
|
const fromRoute = nextRoutes[fromIndex]
|
||||||
|
nextRoutes[fromIndex] = nextRoutes[toIndex]
|
||||||
|
nextRoutes[toIndex] = fromRoute
|
||||||
|
const fromRouteKey = nextRouteKeys[fromIndex]
|
||||||
|
nextRouteKeys[fromIndex] = nextRouteKeys[toIndex]
|
||||||
|
nextRouteKeys[toIndex] = fromRouteKey
|
||||||
|
replaceRoutes(nextRoutes, nextRouteKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveRouteWithinGroup = (index: number, direction: -1 | 1) => {
|
||||||
|
const incomingPath = getRouteIncomingPath(routes[index])
|
||||||
|
const samePathIndexes = routes
|
||||||
|
.map((route, routeIndex) => ({ route, routeIndex }))
|
||||||
|
.filter(({ route }) => getRouteIncomingPath(route) === incomingPath)
|
||||||
|
.map(({ routeIndex }) => routeIndex)
|
||||||
|
const position = samePathIndexes.indexOf(index)
|
||||||
|
const nextIndex = samePathIndexes.at(position + direction)
|
||||||
|
if (nextIndex === undefined) return
|
||||||
|
swapRoutes(index, nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveRouteToGroupEnd = (index: number) => {
|
||||||
|
const incomingPath = getRouteIncomingPath(routes[index])
|
||||||
|
let lastSamePathIndex = -1
|
||||||
|
for (let routeIndex = routes.length - 1; routeIndex >= 0; routeIndex -= 1) {
|
||||||
|
if (getRouteIncomingPath(routes[routeIndex]) === incomingPath) {
|
||||||
|
lastSamePathIndex = routeIndex
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastSamePathIndex < 0 || index === lastSamePathIndex) return
|
||||||
|
|
||||||
|
const nextRoutes = [...routes]
|
||||||
|
const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey)
|
||||||
|
const [route] = nextRoutes.splice(index, 1)
|
||||||
|
const [routeKey] = nextRouteKeys.splice(index, 1)
|
||||||
|
nextRoutes.splice(lastSamePathIndex, 0, route)
|
||||||
|
nextRouteKeys.splice(lastSamePathIndex, 0, routeKey)
|
||||||
|
replaceRoutes(nextRoutes, nextRouteKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixCatchAllOrder = () => {
|
||||||
|
const routeRowsByPath = new Map<string, AdvancedCustomRouteRow[]>()
|
||||||
|
for (const routeRow of routeRows) {
|
||||||
|
const incomingPath = getRouteIncomingPath(routeRow.route)
|
||||||
|
routeRowsByPath.set(incomingPath, [
|
||||||
|
...(routeRowsByPath.get(incomingPath) || []),
|
||||||
|
routeRow,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedRowsByPath = new Map<string, AdvancedCustomRouteRow[]>()
|
||||||
|
for (const [incomingPath, rows] of routeRowsByPath) {
|
||||||
|
orderedRowsByPath.set(incomingPath, [
|
||||||
|
...rows.filter((routeRow) => !isCatchAllRoute(routeRow.route)),
|
||||||
|
...rows.filter((routeRow) => isCatchAllRoute(routeRow.route)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRows = routeRows.map((routeRow) => {
|
||||||
|
const incomingPath = getRouteIncomingPath(routeRow.route)
|
||||||
|
const orderedRows = orderedRowsByPath.get(incomingPath)
|
||||||
|
return orderedRows?.shift() || routeRow
|
||||||
|
})
|
||||||
|
replaceRoutes(
|
||||||
|
nextRows.map((routeRow) => routeRow.route),
|
||||||
|
nextRows.map((routeRow) => routeRow.routeKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
const parseJsonEditorConfig = (): AdvancedCustomConfig | null => {
|
||||||
const parsed = parseAdvancedCustomConfig(jsonText)
|
const parsed = parseAdvancedCustomConfig(jsonText)
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
@@ -302,7 +505,7 @@ export function AdvancedCustomEditorDialog({
|
|||||||
{t('Cancel')}
|
{t('Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='button' onClick={saveConfig}>
|
<Button type='button' onClick={saveConfig}>
|
||||||
<Check className='mr-2 h-4 w-4' />
|
<Check data-icon='inline-start' />
|
||||||
{t('Save changes')}
|
{t('Save changes')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -395,18 +598,30 @@ export function AdvancedCustomEditorDialog({
|
|||||||
size='sm'
|
size='sm'
|
||||||
onClick={addRoute}
|
onClick={addRoute}
|
||||||
>
|
>
|
||||||
<Plus className='mr-2 h-4 w-4' />
|
<Plus data-icon='inline-start' />
|
||||||
{t('Add route')}
|
{t('Add route')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{validationError ? (
|
{validationError ? (
|
||||||
<Alert variant='destructive'>
|
<Alert variant='destructive'>
|
||||||
<AlertDescription>
|
<AlertDescription className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
{validationError.routeIndex !== undefined
|
<span>
|
||||||
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
{validationError.routeIndex !== undefined
|
||||||
: ''}
|
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
||||||
{t(validationError.message)}
|
: ''}
|
||||||
|
{t(validationError.message)}
|
||||||
|
</span>
|
||||||
|
{canFixCatchAllOrder ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={fixCatchAllOrder}
|
||||||
|
>
|
||||||
|
{t('Fix order')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -415,27 +630,24 @@ export function AdvancedCustomEditorDialog({
|
|||||||
{t(upstreamPathDescriptionKey)}
|
{t(upstreamPathDescriptionKey)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className='flex flex-col gap-4 lg:gap-2'>
|
<div className='flex flex-col gap-4'>
|
||||||
<div
|
{routeGroups.map((routeGroup) => (
|
||||||
className={cn(
|
<RouteGroupEditor
|
||||||
'text-muted-foreground hidden items-center gap-2 px-3 text-xs font-medium lg:grid',
|
key={routeGroup.incomingPath || 'advanced-custom-empty-path'}
|
||||||
routeEditorGridClassName
|
group={routeGroup}
|
||||||
)}
|
validationError={validationError}
|
||||||
>
|
onAddRoute={() =>
|
||||||
<span>{t('Route')}</span>
|
addRouteForIncomingPath(routeGroup.incomingPath)
|
||||||
<span>{t('Incoming path')}</span>
|
}
|
||||||
<span>{t('Upstream path')}</span>
|
onIncomingPathChange={(nextIncomingPath) =>
|
||||||
<span>{t('Converter')}</span>
|
updateGroupIncomingPath(routeGroup, nextIncomingPath)
|
||||||
<span>{t('Auth')}</span>
|
}
|
||||||
<span aria-hidden='true' />
|
onMoveRoute={(index, direction) =>
|
||||||
</div>
|
moveRouteWithinGroup(index, direction)
|
||||||
{routeRows.map((routeRow, index) => (
|
}
|
||||||
<RouteEditor
|
onMoveRouteToEnd={moveRouteToGroupEnd}
|
||||||
key={routeRow.routeKey}
|
onRemoveRoute={removeRoute}
|
||||||
route={routeRow.route}
|
onRouteChange={updateRoute}
|
||||||
index={index}
|
|
||||||
onChange={(patch) => updateRoute(index, patch)}
|
|
||||||
onRemove={() => removeRoute(index)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -476,15 +688,191 @@ export function AdvancedCustomEditorDialog({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteGroupEditor({
|
||||||
|
group,
|
||||||
|
validationError,
|
||||||
|
onAddRoute,
|
||||||
|
onIncomingPathChange,
|
||||||
|
onMoveRoute,
|
||||||
|
onMoveRouteToEnd,
|
||||||
|
onRemoveRoute,
|
||||||
|
onRouteChange,
|
||||||
|
}: {
|
||||||
|
group: AdvancedCustomRouteGroup
|
||||||
|
validationError: ReturnType<typeof validateAdvancedCustomConfig>
|
||||||
|
onAddRoute: () => void
|
||||||
|
onIncomingPathChange: (incomingPath: string | null) => void
|
||||||
|
onMoveRoute: (index: number, direction: -1 | 1) => void
|
||||||
|
onMoveRouteToEnd: (index: number) => void
|
||||||
|
onRemoveRoute: (index: number) => void
|
||||||
|
onRouteChange: (index: number, patch: Partial<AdvancedCustomRoute>) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const incomingPath = group.incomingPath || '/v1/chat/completions'
|
||||||
|
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
||||||
|
const catchAllRoute = group.routeRows.find((routeRow) =>
|
||||||
|
isCatchAllRoute(routeRow.route)
|
||||||
|
)
|
||||||
|
const catchAllRoutePosition = catchAllRoute
|
||||||
|
? group.routeRows.findIndex(
|
||||||
|
(routeRow) => routeRow.index === catchAllRoute.index
|
||||||
|
)
|
||||||
|
: -1
|
||||||
|
const hasCatchAll = catchAllRoute !== undefined
|
||||||
|
const catchAllIsLast =
|
||||||
|
!hasCatchAll || catchAllRoutePosition === group.routeRows.length - 1
|
||||||
|
const groupHasError =
|
||||||
|
validationError?.routeIndex !== undefined &&
|
||||||
|
group.routeRows.some(
|
||||||
|
(routeRow) => routeRow.index === validationError.routeIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
'border-border overflow-hidden rounded-md border',
|
||||||
|
groupHasError && 'border-destructive/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className='bg-muted/20 flex flex-col gap-3 p-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||||
|
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<span className='text-sm font-medium'>{t('Route group')}</span>
|
||||||
|
<Badge variant='secondary'>
|
||||||
|
{group.routeRows.length} {t('Routes')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={hasCatchAll ? 'outline' : 'secondary'}>
|
||||||
|
{hasCatchAll ? t('Fallback route') : t('Model-scoped only')}
|
||||||
|
</Badge>
|
||||||
|
{!catchAllIsLast ? (
|
||||||
|
<Badge variant='destructive'>{t('Fallback must be last')}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Select value={incomingPath} onValueChange={onIncomingPathChange}>
|
||||||
|
<SelectTrigger className='h-9 max-w-full lg:max-w-[420px]'>
|
||||||
|
<SelectValue className='min-w-0 truncate'>
|
||||||
|
{incomingPathLabel}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent
|
||||||
|
alignItemWithTrigger={false}
|
||||||
|
className={longSelectContentClass}
|
||||||
|
>
|
||||||
|
<SelectGroup>
|
||||||
|
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
|
||||||
|
<SelectItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
className={longSelectItemClass}
|
||||||
|
>
|
||||||
|
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span className='text-muted-foreground font-mono text-xs break-all'>
|
||||||
|
{option.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type='button' variant='outline' size='sm' onClick={onAddRoute}>
|
||||||
|
<Plus data-icon='inline-start' />
|
||||||
|
{t('Add split')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-t px-3 py-2'>
|
||||||
|
<p className='text-muted-foreground text-xs leading-relaxed'>
|
||||||
|
{t(
|
||||||
|
'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{groupHasError && validationError ? (
|
||||||
|
<p className='text-destructive mt-1 text-xs'>
|
||||||
|
{validationError.routeIndex !== undefined
|
||||||
|
? `${t('Route')} ${validationError.routeIndex + 1}: `
|
||||||
|
: ''}
|
||||||
|
{t(validationError.message)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground hidden items-center gap-2 border-t bg-muted/10 px-3 py-2 text-xs font-medium lg:grid',
|
||||||
|
routeEditorGridClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{t('Route')}</span>
|
||||||
|
<span className='inline-flex items-center gap-1'>
|
||||||
|
{t('Client model')}
|
||||||
|
<ModelRuleHelpPopover />
|
||||||
|
</span>
|
||||||
|
<span>{t('Upstream path')}</span>
|
||||||
|
<span>{t('Converter')}</span>
|
||||||
|
<span>{t('Auth')}</span>
|
||||||
|
<span className='text-right'>{t('Actions')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='divide-y'>
|
||||||
|
{group.routeRows.map((routeRow, position) => {
|
||||||
|
const canMoveUp = position > 0
|
||||||
|
const canMoveDown = position < group.routeRows.length - 1
|
||||||
|
const catchAllOutOfOrder =
|
||||||
|
isCatchAllRoute(routeRow.route) && canMoveDown
|
||||||
|
const routeErrorMessage =
|
||||||
|
validationError?.routeIndex === routeRow.index
|
||||||
|
? validationError.message
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouteEditor
|
||||||
|
key={routeRow.routeKey}
|
||||||
|
route={routeRow.route}
|
||||||
|
index={routeRow.index}
|
||||||
|
errorMessage={routeErrorMessage}
|
||||||
|
canMoveUp={canMoveUp}
|
||||||
|
canMoveDown={canMoveDown}
|
||||||
|
catchAllOutOfOrder={catchAllOutOfOrder}
|
||||||
|
onChange={(patch) => onRouteChange(routeRow.index, patch)}
|
||||||
|
onMoveDown={() => onMoveRoute(routeRow.index, 1)}
|
||||||
|
onMoveUp={() => onMoveRoute(routeRow.index, -1)}
|
||||||
|
onMoveCatchAllToEnd={() => onMoveRouteToEnd(routeRow.index)}
|
||||||
|
onRemove={() => onRemoveRoute(routeRow.index)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function RouteEditor({
|
function RouteEditor({
|
||||||
route,
|
route,
|
||||||
index,
|
index,
|
||||||
|
errorMessage,
|
||||||
|
canMoveUp,
|
||||||
|
canMoveDown,
|
||||||
|
catchAllOutOfOrder,
|
||||||
onChange,
|
onChange,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onMoveCatchAllToEnd,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
route: AdvancedCustomRoute
|
route: AdvancedCustomRoute
|
||||||
index: number
|
index: number
|
||||||
|
errorMessage?: string
|
||||||
|
canMoveUp: boolean
|
||||||
|
canMoveDown: boolean
|
||||||
|
catchAllOutOfOrder: boolean
|
||||||
onChange: (patch: Partial<AdvancedCustomRoute>) => void
|
onChange: (patch: Partial<AdvancedCustomRoute>) => void
|
||||||
|
onMoveUp: () => void
|
||||||
|
onMoveDown: () => void
|
||||||
|
onMoveCatchAllToEnd: () => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -496,39 +884,52 @@ function RouteEditor({
|
|||||||
() => getAdvancedCustomConverterOptions(incomingPath),
|
() => getAdvancedCustomConverterOptions(incomingPath),
|
||||||
[incomingPath]
|
[incomingPath]
|
||||||
)
|
)
|
||||||
const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath)
|
|
||||||
const converterLabel = getOptionLabel(
|
const converterLabel = getOptionLabel(
|
||||||
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS,
|
||||||
converter
|
converter
|
||||||
)
|
)
|
||||||
|
const converterTriggerLabel =
|
||||||
|
ADVANCED_CUSTOM_CONVERTER_OPTIONS.find(
|
||||||
|
(option) => option.value === converter
|
||||||
|
)?.triggerLabel || converterLabel
|
||||||
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode)
|
||||||
const isNativeConverter = converter === 'none'
|
const isNativeConverter = converter === 'none'
|
||||||
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle
|
||||||
|
const modelsInputValue = route.models?.join(', ') || ''
|
||||||
|
const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue)
|
||||||
|
const isFallback = parsedRouteModels.length === 0
|
||||||
|
|
||||||
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
const setConverter = (nextConverter: AdvancedCustomConverter) => {
|
||||||
const patch: Partial<AdvancedCustomRoute> = { converter: nextConverter }
|
let nextIncomingPath = incomingPath
|
||||||
if (!isAdvancedCustomIncomingPathAllowed(incomingPath, nextConverter)) {
|
if (!isAdvancedCustomIncomingPathAllowed(nextIncomingPath, nextConverter)) {
|
||||||
patch.incoming_path = getDefaultAdvancedCustomIncomingPath(nextConverter)
|
nextIncomingPath = getDefaultAdvancedCustomIncomingPath(nextConverter)
|
||||||
}
|
}
|
||||||
onChange(patch)
|
const defaults = getAdvancedCustomConverterDefaults(
|
||||||
}
|
nextConverter,
|
||||||
|
nextIncomingPath
|
||||||
const setIncomingPath = (nextIncomingPath: string | null) => {
|
)
|
||||||
const resolvedIncomingPath =
|
onChange({
|
||||||
nextIncomingPath || getDefaultAdvancedCustomIncomingPath(converter)
|
converter: nextConverter,
|
||||||
const patch: Partial<AdvancedCustomRoute> = {
|
incoming_path: nextIncomingPath,
|
||||||
incoming_path: resolvedIncomingPath,
|
upstream_path: defaults.upstream_path,
|
||||||
}
|
auth: defaults.auth,
|
||||||
if (!isAdvancedCustomIncomingPathAllowed(resolvedIncomingPath, converter)) {
|
})
|
||||||
patch.converter = 'none'
|
|
||||||
}
|
|
||||||
onChange(patch)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
const setAuthMode = (mode: AdvancedCustomAuthMode) => {
|
||||||
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setModelsInput = (value: string) => {
|
||||||
|
onChange({
|
||||||
|
models: value === '' ? [] : value.split(','),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeModelsInput = (value: string) => {
|
||||||
|
onChange({ models: parseAdvancedCustomRouteModels(value) })
|
||||||
|
}
|
||||||
|
|
||||||
const updateAuth = (
|
const updateAuth = (
|
||||||
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, 'type'>,
|
field: Exclude<keyof NonNullable<AdvancedCustomRoute['auth']>, 'type'>,
|
||||||
value: string
|
value: string
|
||||||
@@ -546,7 +947,12 @@ function RouteEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='border-border flex flex-col gap-4 rounded-md border p-4 lg:gap-2 lg:p-3'>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-4 px-4 py-4 lg:gap-2 lg:px-3 lg:py-3',
|
||||||
|
errorMessage && 'bg-destructive/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid gap-4 md:grid-cols-2 lg:items-center lg:gap-2',
|
'grid gap-4 md:grid-cols-2 lg:items-center lg:gap-2',
|
||||||
@@ -559,6 +965,9 @@ function RouteEditor({
|
|||||||
<div className='text-sm font-medium'>
|
<div className='text-sm font-medium'>
|
||||||
{t('Route')} {index + 1}
|
{t('Route')} {index + 1}
|
||||||
</div>
|
</div>
|
||||||
|
{isFallback ? (
|
||||||
|
<Badge variant='outline'>{t('Fallback')}</Badge>
|
||||||
|
) : null}
|
||||||
<TooltipProvider delay={100}>
|
<TooltipProvider delay={100}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@@ -586,51 +995,80 @@ function RouteEditor({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className='flex shrink-0 items-center gap-1 lg:hidden'>
|
||||||
type='button'
|
<TooltipIconButton
|
||||||
variant='ghost'
|
label={t('Move route up')}
|
||||||
size='icon'
|
icon={ArrowUp}
|
||||||
className='lg:hidden'
|
disabled={!canMoveUp}
|
||||||
onClick={onRemove}
|
onClick={onMoveUp}
|
||||||
>
|
/>
|
||||||
<Trash2 className='h-4 w-4' />
|
<TooltipIconButton
|
||||||
<span className='sr-only'>{t('Delete')}</span>
|
label={t('Move route down')}
|
||||||
</Button>
|
icon={ArrowDown}
|
||||||
|
disabled={!canMoveDown}
|
||||||
|
onClick={onMoveDown}
|
||||||
|
/>
|
||||||
|
{catchAllOutOfOrder ? (
|
||||||
|
<TooltipIconButton
|
||||||
|
label={t('Move fallback to end')}
|
||||||
|
icon={ArrowDownToLine}
|
||||||
|
onClick={onMoveCatchAllToEnd}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TooltipIconButton
|
||||||
|
label={t('Delete')}
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
label={t('Incoming path')}
|
label={
|
||||||
|
<span className='inline-flex items-center gap-1'>
|
||||||
|
{t('Client model')}
|
||||||
|
<ModelRuleHelpPopover />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
className='lg:gap-1'
|
className='lg:gap-1'
|
||||||
labelClassName='lg:sr-only'
|
labelClassName='lg:sr-only'
|
||||||
>
|
>
|
||||||
<Select value={incomingPath} onValueChange={setIncomingPath}>
|
<Input
|
||||||
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
value={modelsInputValue}
|
||||||
<SelectValue className='min-w-0 truncate'>
|
onChange={(event) => setModelsInput(event.target.value)}
|
||||||
{`${incomingPathLabel}`}
|
onBlur={(event) => normalizeModelsInput(event.target.value)}
|
||||||
</SelectValue>
|
placeholder={
|
||||||
</SelectTrigger>
|
isFallback
|
||||||
<SelectContent
|
? t('Leave empty for fallback')
|
||||||
alignItemWithTrigger={false}
|
: t('e.g. gpt-4o, gemini-2.5-flash')
|
||||||
className={longSelectContentClass}
|
}
|
||||||
>
|
aria-invalid={Boolean(errorMessage)}
|
||||||
<SelectGroup>
|
/>
|
||||||
{ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.map((option) => (
|
<div className='flex flex-wrap gap-1'>
|
||||||
<SelectItem
|
{isFallback ? (
|
||||||
key={option.value}
|
<Badge variant='outline'>{t('Fallback')}</Badge>
|
||||||
value={option.value}
|
) : (
|
||||||
className={longSelectItemClass}
|
parsedRouteModels.map((model) => {
|
||||||
|
const ruleKind = getAdvancedCustomModelRuleKind(model)
|
||||||
|
const displayModel =
|
||||||
|
ruleKind === 'regex'
|
||||||
|
? getAdvancedCustomRegexModelPattern(model) || model
|
||||||
|
: model
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={model}
|
||||||
|
variant={ruleKind === 'regex' ? 'outline' : 'secondary'}
|
||||||
|
className='max-w-full gap-1.5 font-mono'
|
||||||
>
|
>
|
||||||
<div className='flex min-w-0 flex-col gap-1 leading-snug whitespace-normal'>
|
<span className='font-sans text-[10px] font-semibold tracking-normal uppercase'>
|
||||||
<span>{option.label}</span>
|
{t(ruleKind === 'regex' ? 'Regex' : 'Exact')}
|
||||||
<span className='text-muted-foreground font-mono text-xs break-all'>
|
</span>
|
||||||
{option.value}
|
<span className='truncate'>{displayModel}</span>
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
)
|
||||||
</SelectItem>
|
})
|
||||||
))}
|
)}
|
||||||
</SelectGroup>
|
</div>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
<FieldBlock
|
<FieldBlock
|
||||||
@@ -645,7 +1083,10 @@ function RouteEditor({
|
|||||||
upstream_path: event.target.value,
|
upstream_path: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder={getAdvancedCustomUpstreamPathPlaceholder(converter)}
|
placeholder={getAdvancedCustomUpstreamPathPlaceholder(
|
||||||
|
converter,
|
||||||
|
incomingPath
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<p className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
|
<p className='text-muted-foreground text-xs leading-relaxed lg:hidden'>
|
||||||
{t(upstreamPathDescriptionKey)}
|
{t(upstreamPathDescriptionKey)}
|
||||||
@@ -665,7 +1106,7 @@ function RouteEditor({
|
|||||||
>
|
>
|
||||||
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
<SelectTrigger className='w-full max-w-full lg:h-8'>
|
||||||
<SelectValue className='min-w-0 truncate'>
|
<SelectValue className='min-w-0 truncate'>
|
||||||
{t(converterLabel)}
|
{t(converterTriggerLabel)}
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
@@ -717,18 +1158,38 @@ function RouteEditor({
|
|||||||
</Select>
|
</Select>
|
||||||
</FieldBlock>
|
</FieldBlock>
|
||||||
|
|
||||||
<Button
|
<div className='hidden items-center justify-end gap-1 lg:flex'>
|
||||||
type='button'
|
<TooltipIconButton
|
||||||
variant='ghost'
|
label={t('Move route up')}
|
||||||
size='icon'
|
icon={ArrowUp}
|
||||||
className='hidden lg:inline-flex'
|
disabled={!canMoveUp}
|
||||||
onClick={onRemove}
|
onClick={onMoveUp}
|
||||||
>
|
/>
|
||||||
<Trash2 className='h-4 w-4' />
|
<TooltipIconButton
|
||||||
<span className='sr-only'>{t('Delete')}</span>
|
label={t('Move route down')}
|
||||||
</Button>
|
icon={ArrowDown}
|
||||||
|
disabled={!canMoveDown}
|
||||||
|
onClick={onMoveDown}
|
||||||
|
/>
|
||||||
|
{catchAllOutOfOrder ? (
|
||||||
|
<TooltipIconButton
|
||||||
|
label={t('Move fallback to end')}
|
||||||
|
icon={ArrowDownToLine}
|
||||||
|
onClick={onMoveCatchAllToEnd}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<TooltipIconButton
|
||||||
|
label={t('Delete')}
|
||||||
|
icon={Trash2}
|
||||||
|
onClick={onRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className='text-destructive text-xs'>{t(errorMessage)}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{authMode === 'header' || authMode === 'query' ? (
|
{authMode === 'header' || authMode === 'query' ? (
|
||||||
<>
|
<>
|
||||||
<Separator className='lg:hidden' />
|
<Separator className='lg:hidden' />
|
||||||
@@ -775,13 +1236,101 @@ function RouteEditor({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModelRuleHelpPopover() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='text-muted-foreground hover:text-foreground size-6'
|
||||||
|
aria-label={t('Client model matching help')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Info className='size-3.5' aria-hidden='true' />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align='start'
|
||||||
|
side='bottom'
|
||||||
|
sideOffset={8}
|
||||||
|
className='w-[min(22rem,calc(100vw-2rem))] gap-3 p-3'
|
||||||
|
>
|
||||||
|
<PopoverHeader className='gap-1'>
|
||||||
|
<PopoverTitle>{t('Client model matching')}</PopoverTitle>
|
||||||
|
<PopoverDescription className='text-xs leading-relaxed'>
|
||||||
|
{t(
|
||||||
|
'Rules match the original model value from the client request body.'
|
||||||
|
)}
|
||||||
|
</PopoverDescription>
|
||||||
|
</PopoverHeader>
|
||||||
|
<div className='text-muted-foreground space-y-2 text-xs leading-relaxed'>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Leave the final split empty as the fallback for models not matched above.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipIconButton({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
disabled?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider delay={100}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon data-icon='inline-start' aria-hidden='true' />
|
||||||
|
<span className='sr-only'>{label}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side='top'>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FieldBlock({
|
function FieldBlock({
|
||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
labelClassName,
|
labelClassName,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
labelClassName?: string
|
labelClassName?: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|||||||
+196
-14
@@ -29,31 +29,47 @@ export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
|
|||||||
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
||||||
value: AdvancedCustomConverter
|
value: AdvancedCustomConverter
|
||||||
label: string
|
label: string
|
||||||
|
triggerLabel: string
|
||||||
}> = [
|
}> = [
|
||||||
{ value: 'none', label: 'Native forwarding' },
|
{
|
||||||
|
value: 'none',
|
||||||
|
label: 'Native forwarding',
|
||||||
|
triggerLabel: 'Native forwarding',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: 'anthropic_messages_to_openai_chat_completions',
|
value: 'anthropic_messages_to_openai_chat_completions',
|
||||||
label: 'Anthropic Messages to OpenAI Chat',
|
label: 'Anthropic Messages to OpenAI Chat',
|
||||||
|
triggerLabel: 'To OpenAI Chat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'openai_chat_completions_to_anthropic_messages',
|
value: 'openai_chat_completions_to_anthropic_messages',
|
||||||
label: 'OpenAI Chat to Anthropic Messages',
|
label: 'OpenAI Chat to Anthropic Messages',
|
||||||
|
triggerLabel: 'To Anthropic Messages',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'openai_chat_completions_to_openai_responses',
|
value: 'openai_chat_completions_to_openai_responses',
|
||||||
label: 'OpenAI Chat to OpenAI Responses',
|
label: 'OpenAI Chat to OpenAI Responses',
|
||||||
|
triggerLabel: 'To OpenAI Responses',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'openai_responses_to_openai_chat_completions',
|
value: 'openai_responses_to_openai_chat_completions',
|
||||||
label: 'OpenAI Responses to OpenAI Chat',
|
label: 'OpenAI Responses to OpenAI Chat',
|
||||||
|
triggerLabel: 'To OpenAI Chat',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'openai_responses_to_gemini_generate_content',
|
||||||
|
label: 'OpenAI Responses to Gemini Generate Content',
|
||||||
|
triggerLabel: 'To Gemini Generate Content',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'gemini_generate_content_to_openai_chat_completions',
|
value: 'gemini_generate_content_to_openai_chat_completions',
|
||||||
label: 'Gemini Generate Content to OpenAI Chat',
|
label: 'Gemini Generate Content to OpenAI Chat',
|
||||||
|
triggerLabel: 'To OpenAI Chat',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'openai_chat_completions_to_gemini_generate_content',
|
value: 'openai_chat_completions_to_gemini_generate_content',
|
||||||
label: 'OpenAI Chat to Gemini Generate Content',
|
label: 'OpenAI Chat to Gemini Generate Content',
|
||||||
|
triggerLabel: 'To Gemini Generate Content',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -157,6 +173,20 @@ export type AdvancedCustomTemplateOption = {
|
|||||||
config: AdvancedCustomConfig
|
config: AdvancedCustomConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdvancedCustomConverterDefaults = {
|
||||||
|
upstream_path: string
|
||||||
|
auth?: AdvancedCustomRouteAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADVANCED_CUSTOM_MODEL_REGEX_PREFIX = 're:'
|
||||||
|
|
||||||
|
export type AdvancedCustomModelRuleKind = 'exact' | 'regex'
|
||||||
|
|
||||||
|
const openAIChatPath = '/v1/chat/completions'
|
||||||
|
const openAIResponsesPath = '/v1/responses'
|
||||||
|
const claudeMessagesPath = '/v1/messages'
|
||||||
|
const geminiGenerateContentPath = '/v1beta/models/{model}:generateContent'
|
||||||
|
|
||||||
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
||||||
type: 'header',
|
type: 'header',
|
||||||
name: 'Authorization',
|
name: 'Authorization',
|
||||||
@@ -313,8 +343,8 @@ export function getAdvancedCustomTemplateConfig(
|
|||||||
|
|
||||||
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
|
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
|
||||||
return {
|
return {
|
||||||
incoming_path: '/v1/chat/completions',
|
incoming_path: openAIChatPath,
|
||||||
upstream_path: '/v1/chat/completions',
|
upstream_path: openAIChatPath,
|
||||||
converter: 'none',
|
converter: 'none',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,18 +356,67 @@ export function createAdvancedCustomConfig(): AdvancedCustomConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAdvancedCustomUpstreamPathPlaceholder(
|
export function getAdvancedCustomUpstreamPathPlaceholder(
|
||||||
converter: AdvancedCustomConverter
|
converter: AdvancedCustomConverter,
|
||||||
|
incomingPath = getDefaultAdvancedCustomIncomingPath(converter)
|
||||||
): string {
|
): string {
|
||||||
if (converter === 'openai_chat_completions_to_gemini_generate_content') {
|
return getAdvancedCustomConverterDefaults(converter, incomingPath)
|
||||||
return '/v1beta/models/{model}:generateContent'
|
.upstream_path
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdvancedCustomConverterDefaults(
|
||||||
|
converter: AdvancedCustomConverter,
|
||||||
|
incomingPath: string
|
||||||
|
): AdvancedCustomConverterDefaults {
|
||||||
|
const normalizedIncomingPath =
|
||||||
|
incomingPath.trim() || getDefaultAdvancedCustomIncomingPath(converter)
|
||||||
|
|
||||||
|
if (converter === 'none') {
|
||||||
|
return {
|
||||||
|
upstream_path: normalizedIncomingPath,
|
||||||
|
auth: getAdvancedCustomNativeAuth(normalizedIncomingPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
converter === 'anthropic_messages_to_openai_chat_completions' ||
|
||||||
|
converter === 'gemini_generate_content_to_openai_chat_completions' ||
|
||||||
|
converter === 'openai_responses_to_openai_chat_completions'
|
||||||
|
) {
|
||||||
|
return { upstream_path: openAIChatPath, auth: bearerHeaderAuth() }
|
||||||
|
}
|
||||||
|
if (converter === 'openai_chat_completions_to_openai_responses') {
|
||||||
|
return { upstream_path: openAIResponsesPath, auth: bearerHeaderAuth() }
|
||||||
}
|
}
|
||||||
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
||||||
return '/v1/messages'
|
return { upstream_path: claudeMessagesPath, auth: apiKeyHeaderAuth() }
|
||||||
}
|
}
|
||||||
if (converter === 'openai_responses_to_openai_chat_completions') {
|
if (
|
||||||
return '/v1/chat/completions'
|
converter === 'openai_chat_completions_to_gemini_generate_content' ||
|
||||||
|
converter === 'openai_responses_to_gemini_generate_content'
|
||||||
|
) {
|
||||||
|
return { upstream_path: geminiGenerateContentPath, auth: geminiQueryAuth() }
|
||||||
}
|
}
|
||||||
return '/v1/chat/completions'
|
|
||||||
|
return {
|
||||||
|
upstream_path: normalizedIncomingPath || openAIChatPath,
|
||||||
|
auth: getAdvancedCustomNativeAuth(normalizedIncomingPath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdvancedCustomNativeAuth(
|
||||||
|
incomingPath: string
|
||||||
|
): AdvancedCustomRouteAuth {
|
||||||
|
if (incomingPath === claudeMessagesPath) {
|
||||||
|
return apiKeyHeaderAuth()
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
incomingPath.includes(':generateContent') ||
|
||||||
|
incomingPath.includes(':streamGenerateContent') ||
|
||||||
|
incomingPath.includes(':embedContent') ||
|
||||||
|
incomingPath.includes(':batchEmbedContents')
|
||||||
|
) {
|
||||||
|
return geminiQueryAuth()
|
||||||
|
}
|
||||||
|
return bearerHeaderAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdvancedCustomIncomingPathOptions(
|
export function getAdvancedCustomIncomingPathOptions(
|
||||||
@@ -416,6 +495,29 @@ export function normalizeAdvancedCustomConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAdvancedCustomRouteModels(value: string): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((model) => model.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdvancedCustomModelRuleKind(
|
||||||
|
modelRule: string
|
||||||
|
): AdvancedCustomModelRuleKind {
|
||||||
|
return modelRule.startsWith(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX)
|
||||||
|
? 'regex'
|
||||||
|
: 'exact'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdvancedCustomRegexModelPattern(modelRule: string): string {
|
||||||
|
return modelRule.slice(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
export function validateAdvancedCustomConfig(
|
export function validateAdvancedCustomConfig(
|
||||||
config: AdvancedCustomConfig | null
|
config: AdvancedCustomConfig | null
|
||||||
): AdvancedCustomValidationError | null {
|
): AdvancedCustomValidationError | null {
|
||||||
@@ -431,12 +533,16 @@ export function validateAdvancedCustomConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const seenPaths = new Set<string>()
|
const routeModelsByPath = new Map<
|
||||||
|
string,
|
||||||
|
{ catchAllIndex: number | null; models: Map<string, number> }
|
||||||
|
>()
|
||||||
for (let index = 0; index < routes.length; index += 1) {
|
for (let index = 0; index < routes.length; index += 1) {
|
||||||
const route = routes[index]
|
const route = routes[index]
|
||||||
const incomingPath = route.incoming_path?.trim() || ''
|
const incomingPath = route.incoming_path?.trim() || ''
|
||||||
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
|
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
|
||||||
const converter = route.converter || 'none'
|
const converter = route.converter || 'none'
|
||||||
|
const routeModels = normalizeAdvancedCustomRouteModels(route.models)
|
||||||
|
|
||||||
if (!incomingPath) {
|
if (!incomingPath) {
|
||||||
return { routeIndex: index, message: 'Incoming path is required' }
|
return { routeIndex: index, message: 'Incoming path is required' }
|
||||||
@@ -450,10 +556,15 @@ export function validateAdvancedCustomConfig(
|
|||||||
message: 'Incoming path must not include query',
|
message: 'Incoming path must not include query',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seenPaths.has(incomingPath)) {
|
const routeModelsError = validateAdvancedCustomRouteModels(
|
||||||
return { routeIndex: index, message: 'Incoming path must be unique' }
|
index,
|
||||||
|
incomingPath,
|
||||||
|
routeModels,
|
||||||
|
routeModelsByPath
|
||||||
|
)
|
||||||
|
if (routeModelsError) {
|
||||||
|
return routeModelsError
|
||||||
}
|
}
|
||||||
seenPaths.add(incomingPath)
|
|
||||||
|
|
||||||
if (!upstreamPath) {
|
if (!upstreamPath) {
|
||||||
return { routeIndex: index, message: 'Upstream path is required' }
|
return { routeIndex: index, message: 'Upstream path is required' }
|
||||||
@@ -555,6 +666,10 @@ function normalizeAdvancedCustomRoute(
|
|||||||
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
|
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
|
||||||
converter: route.converter || 'none',
|
converter: route.converter || 'none',
|
||||||
}
|
}
|
||||||
|
const models = normalizeAdvancedCustomRouteModels(route.models)
|
||||||
|
if (models.length > 0) {
|
||||||
|
nextRoute.models = models
|
||||||
|
}
|
||||||
if (route.auth) {
|
if (route.auth) {
|
||||||
nextRoute.auth = {
|
nextRoute.auth = {
|
||||||
type: route.auth.type,
|
type: route.auth.type,
|
||||||
@@ -565,6 +680,70 @@ function normalizeAdvancedCustomRoute(
|
|||||||
return nextRoute
|
return nextRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAdvancedCustomRouteModels(
|
||||||
|
models: string[] | undefined
|
||||||
|
): string[] {
|
||||||
|
if (!Array.isArray(models)) return []
|
||||||
|
return models.map((model) => model.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAdvancedCustomRouteModels(
|
||||||
|
routeIndex: number,
|
||||||
|
incomingPath: string,
|
||||||
|
models: string[],
|
||||||
|
routeModelsByPath: Map<
|
||||||
|
string,
|
||||||
|
{ catchAllIndex: number | null; models: Map<string, number> }
|
||||||
|
>
|
||||||
|
): AdvancedCustomValidationError | null {
|
||||||
|
let state = routeModelsByPath.get(incomingPath)
|
||||||
|
if (!state) {
|
||||||
|
state = { catchAllIndex: null, models: new Map<string, number>() }
|
||||||
|
routeModelsByPath.set(incomingPath, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (models.length === 0) {
|
||||||
|
if (state.catchAllIndex !== null) {
|
||||||
|
return {
|
||||||
|
routeIndex,
|
||||||
|
message:
|
||||||
|
'Only one catch-all route is allowed for the same incoming path',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.catchAllIndex = routeIndex
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.catchAllIndex !== null) {
|
||||||
|
return {
|
||||||
|
routeIndex,
|
||||||
|
message: 'Catch-all route must be last for the same incoming path',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenInRoute = new Set<string>()
|
||||||
|
for (const model of models) {
|
||||||
|
if (
|
||||||
|
getAdvancedCustomModelRuleKind(model) === 'regex' &&
|
||||||
|
getAdvancedCustomRegexModelPattern(model) === ''
|
||||||
|
) {
|
||||||
|
return { routeIndex, message: 'Model regex cannot be empty' }
|
||||||
|
}
|
||||||
|
if (seenInRoute.has(model)) {
|
||||||
|
return { routeIndex, message: 'Duplicate model in route models' }
|
||||||
|
}
|
||||||
|
seenInRoute.add(model)
|
||||||
|
if (state.models.has(model)) {
|
||||||
|
return {
|
||||||
|
routeIndex,
|
||||||
|
message: 'Route models must be unique for the same incoming path',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.models.set(model, routeIndex)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function getAdvancedCustomRouteUpstreamPath(
|
function getAdvancedCustomRouteUpstreamPath(
|
||||||
route: AdvancedCustomRoute
|
route: AdvancedCustomRoute
|
||||||
): string {
|
): string {
|
||||||
@@ -622,6 +801,9 @@ function isConverterPathAllowed(
|
|||||||
if (converter === 'openai_responses_to_openai_chat_completions') {
|
if (converter === 'openai_responses_to_openai_chat_completions') {
|
||||||
return incomingPath === '/v1/responses'
|
return incomingPath === '/v1/responses'
|
||||||
}
|
}
|
||||||
|
if (converter === 'openai_responses_to_gemini_generate_content') {
|
||||||
|
return incomingPath === '/v1/responses'
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
incomingPath.includes(':generateContent') ||
|
incomingPath.includes(':generateContent') ||
|
||||||
incomingPath.includes(':streamGenerateContent')
|
incomingPath.includes(':streamGenerateContent')
|
||||||
|
|||||||
+2
@@ -117,6 +117,7 @@ export interface AdvancedCustomRoute {
|
|||||||
incoming_path?: string
|
incoming_path?: string
|
||||||
upstream_path?: string
|
upstream_path?: string
|
||||||
converter?: AdvancedCustomConverter
|
converter?: AdvancedCustomConverter
|
||||||
|
models?: string[]
|
||||||
auth?: AdvancedCustomRouteAuth
|
auth?: AdvancedCustomRouteAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ export type AdvancedCustomConverter =
|
|||||||
| 'openai_chat_completions_to_anthropic_messages'
|
| 'openai_chat_completions_to_anthropic_messages'
|
||||||
| 'openai_chat_completions_to_openai_responses'
|
| 'openai_chat_completions_to_openai_responses'
|
||||||
| 'openai_responses_to_openai_chat_completions'
|
| 'openai_responses_to_openai_chat_completions'
|
||||||
|
| 'openai_responses_to_gemini_generate_content'
|
||||||
| 'gemini_generate_content_to_openai_chat_completions'
|
| 'gemini_generate_content_to_openai_chat_completions'
|
||||||
| 'openai_chat_completions_to_gemini_generate_content'
|
| 'openai_chat_completions_to_gemini_generate_content'
|
||||||
|
|
||||||
|
|||||||
+42
-10
@@ -31,6 +31,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
LogIn,
|
LogIn,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import type { TFunction } from 'i18next'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { Dialog } from '@/components/dialog'
|
import { Dialog } from '@/components/dialog'
|
||||||
@@ -62,7 +63,7 @@ import {
|
|||||||
isPerCallBilling,
|
isPerCallBilling,
|
||||||
isTimingLogType,
|
isTimingLogType,
|
||||||
} from '../../lib/utils'
|
} from '../../lib/utils'
|
||||||
import type { LogOtherData } from '../../types'
|
import { USAGE_BILLING_PATH, type LogOtherData } from '../../types'
|
||||||
|
|
||||||
// Maps a channel-update changed-field token (as recorded by the backend audit)
|
// Maps a channel-update changed-field token (as recorded by the backend audit)
|
||||||
// to its i18n label key for display in the audit details.
|
// to its i18n label key for display in the audit details.
|
||||||
@@ -150,6 +151,41 @@ function formatRatio(ratio: number | undefined): string {
|
|||||||
return ratio.toFixed(4)
|
return ratio.toFixed(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUsageBillingPathLabel(
|
||||||
|
t: TFunction,
|
||||||
|
adminInfo: LogOtherData['admin_info']
|
||||||
|
): string {
|
||||||
|
switch (adminInfo?.usage_billing_path) {
|
||||||
|
case USAGE_BILLING_PATH.LOCAL:
|
||||||
|
return t('Local Billing')
|
||||||
|
case USAGE_BILLING_PATH.OPENAI:
|
||||||
|
return t('Upstream Response (billing-usage-openai)')
|
||||||
|
case USAGE_BILLING_PATH.OPENAI_ESTIMATED:
|
||||||
|
return t('Upstream Response (billing-usage-openai-estimated)')
|
||||||
|
case USAGE_BILLING_PATH.ANTHROPIC:
|
||||||
|
return t('Upstream Response (billing-usage-anthropic)')
|
||||||
|
case USAGE_BILLING_PATH.ANTHROPIC_ESTIMATED:
|
||||||
|
return t('Upstream Response (billing-usage-anthropic-estimated)')
|
||||||
|
case USAGE_BILLING_PATH.GEMINI:
|
||||||
|
return t('Upstream Response (billing-usage-gemini)')
|
||||||
|
case USAGE_BILLING_PATH.GEMINI_ESTIMATED:
|
||||||
|
return t('Upstream Response (billing-usage-gemini-estimated)')
|
||||||
|
case USAGE_BILLING_PATH.UPSTREAM:
|
||||||
|
return t('Upstream Response')
|
||||||
|
default:
|
||||||
|
return adminInfo?.local_count_tokens
|
||||||
|
? t('Local Billing')
|
||||||
|
: t('Upstream Response')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUsageBillingPathLocal(adminInfo: LogOtherData['admin_info']): boolean {
|
||||||
|
if (adminInfo?.usage_billing_path) {
|
||||||
|
return adminInfo.usage_billing_path === USAGE_BILLING_PATH.LOCAL
|
||||||
|
}
|
||||||
|
return adminInfo?.local_count_tokens === true
|
||||||
|
}
|
||||||
|
|
||||||
function quotaSaturationKindLabel(
|
function quotaSaturationKindLabel(
|
||||||
kind: 'overflow' | 'underflow' | 'nan',
|
kind: 'overflow' | 'underflow' | 'nan',
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
@@ -326,10 +362,8 @@ function BillingBreakdown(props: {
|
|||||||
|
|
||||||
if (isAdmin && other.admin_info) {
|
if (isAdmin && other.admin_info) {
|
||||||
rows.push({
|
rows.push({
|
||||||
label: t('Billing Source'),
|
label: t('Billing Path'),
|
||||||
value: other.admin_info.local_count_tokens
|
value: getUsageBillingPathLabel(t, other.admin_info),
|
||||||
? t('Local Billing')
|
|
||||||
: t('Upstream Response'),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,18 +1071,16 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
|||||||
props.log.type !== 6 &&
|
props.log.type !== 6 &&
|
||||||
other?.admin_info && (
|
other?.admin_info && (
|
||||||
<DetailRow
|
<DetailRow
|
||||||
label={t('Billing Source')}
|
label={t('Billing Path')}
|
||||||
value={
|
value={
|
||||||
<span className='flex items-center gap-1'>
|
<span className='flex items-center gap-1'>
|
||||||
{other.admin_info.local_count_tokens ? (
|
{isUsageBillingPathLocal(other.admin_info) ? (
|
||||||
<Monitor className='size-3 text-blue-500' />
|
<Monitor className='size-3 text-blue-500' />
|
||||||
) : (
|
) : (
|
||||||
<Cloud className='size-3 text-emerald-500' />
|
<Cloud className='size-3 text-emerald-500' />
|
||||||
)}
|
)}
|
||||||
<span className='text-xs'>
|
<span className='text-xs'>
|
||||||
{other.admin_info.local_count_tokens
|
{getUsageBillingPathLabel(t, other.admin_info)}
|
||||||
? t('Local Billing')
|
|
||||||
: t('Upstream Response')}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -92,12 +92,27 @@ export interface ChannelAffinityInfo {
|
|||||||
using_group?: string
|
using_group?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const USAGE_BILLING_PATH = {
|
||||||
|
LOCAL: 'local',
|
||||||
|
UPSTREAM: 'upstream',
|
||||||
|
OPENAI: 'billing-usage-openai',
|
||||||
|
OPENAI_ESTIMATED: 'billing-usage-openai-estimated',
|
||||||
|
ANTHROPIC: 'billing-usage-anthropic',
|
||||||
|
ANTHROPIC_ESTIMATED: 'billing-usage-anthropic-estimated',
|
||||||
|
GEMINI: 'billing-usage-gemini',
|
||||||
|
GEMINI_ESTIMATED: 'billing-usage-gemini-estimated',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type UsageBillingPath =
|
||||||
|
(typeof USAGE_BILLING_PATH)[keyof typeof USAGE_BILLING_PATH]
|
||||||
|
|
||||||
export interface LogOtherData {
|
export interface LogOtherData {
|
||||||
admin_info?: {
|
admin_info?: {
|
||||||
is_multi_key?: boolean
|
is_multi_key?: boolean
|
||||||
multi_key_index?: number
|
multi_key_index?: number
|
||||||
use_channel?: number[]
|
use_channel?: number[]
|
||||||
local_count_tokens?: boolean
|
local_count_tokens?: boolean
|
||||||
|
usage_billing_path?: UsageBillingPath | string
|
||||||
channel_affinity?: ChannelAffinityInfo
|
channel_affinity?: ChannelAffinityInfo
|
||||||
// Top-up audit fields (type=1, admin only)
|
// Top-up audit fields (type=1, admin only)
|
||||||
payment_method?: string
|
payment_method?: string
|
||||||
|
|||||||
Vendored
+54
@@ -207,6 +207,7 @@
|
|||||||
"Add rule group": "Add rule group",
|
"Add rule group": "Add rule group",
|
||||||
"Add rules for a user group": "Add rules for a user group",
|
"Add rules for a user group": "Add rules for a user group",
|
||||||
"Add selectable group": "Add selectable group",
|
"Add selectable group": "Add selectable group",
|
||||||
|
"Add split": "Add split",
|
||||||
"Add subscription": "Add subscription",
|
"Add subscription": "Add subscription",
|
||||||
"Add tags...": "Add tags...",
|
"Add tags...": "Add tags...",
|
||||||
"Add tier": "Add tier",
|
"Add tier": "Add tier",
|
||||||
@@ -617,6 +618,7 @@
|
|||||||
"Billing group = vip (the token has no group, so use the user group)": "Billing group = vip (the token has no group, so use the user group)",
|
"Billing group = vip (the token has no group, so use the user group)": "Billing group = vip (the token has no group, so use the user group)",
|
||||||
"Billing History": "Billing History",
|
"Billing History": "Billing History",
|
||||||
"Billing Mode": "Billing Mode",
|
"Billing Mode": "Billing Mode",
|
||||||
|
"Billing Path": "Billing Path",
|
||||||
"Billing Process": "Billing Process",
|
"Billing Process": "Billing Process",
|
||||||
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.",
|
"Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.",
|
||||||
"Billing Source": "Billing Source",
|
"Billing Source": "Billing Source",
|
||||||
@@ -715,6 +717,7 @@
|
|||||||
"Caps the response length": "Caps the response length",
|
"Caps the response length": "Caps the response length",
|
||||||
"Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.",
|
"Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.",
|
||||||
"Card view": "Card view",
|
"Card view": "Card view",
|
||||||
|
"Catch-all route must be last for the same incoming path": "Catch-all route must be last for the same incoming path",
|
||||||
"Category": "Category",
|
"Category": "Category",
|
||||||
"Category Name": "Category Name",
|
"Category Name": "Category Name",
|
||||||
"Category name is required": "Category name is required",
|
"Category name is required": "Category name is required",
|
||||||
@@ -867,6 +870,9 @@
|
|||||||
"Click to view image": "Click to view image",
|
"Click to view image": "Click to view image",
|
||||||
"Client header value": "Client header value",
|
"Client header value": "Client header value",
|
||||||
"Client ID": "Client ID",
|
"Client ID": "Client ID",
|
||||||
|
"Client model": "Client model",
|
||||||
|
"Client model matching": "Client model matching",
|
||||||
|
"Client model matching help": "Client model matching help",
|
||||||
"Client Secret": "Client Secret",
|
"Client Secret": "Client Secret",
|
||||||
"Close": "Close",
|
"Close": "Close",
|
||||||
"Close dialog": "Close dialog",
|
"Close dialog": "Close dialog",
|
||||||
@@ -1446,6 +1452,7 @@
|
|||||||
"Drawing task records": "Drawing task records",
|
"Drawing task records": "Drawing task records",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
|
"Duplicate group names: {{names}}": "Duplicate group names: {{names}}",
|
||||||
|
"Duplicate model in route models": "Duplicate model in route models",
|
||||||
"Duplicate source model mappings are not allowed": "Duplicate source model mappings are not allowed",
|
"Duplicate source model mappings are not allowed": "Duplicate source model mappings are not allowed",
|
||||||
"Duplicate source model(s): {{models}}": "Duplicate source model(s): {{models}}",
|
"Duplicate source model(s): {{models}}": "Duplicate source model(s): {{models}}",
|
||||||
"Duration": "Duration",
|
"Duration": "Duration",
|
||||||
@@ -1460,6 +1467,7 @@
|
|||||||
"e.g. Basic Plan": "e.g. Basic Plan",
|
"e.g. Basic Plan": "e.g. Basic Plan",
|
||||||
"e.g. Clean tool parameters to avoid upstream validation errors": "e.g. Clean tool parameters to avoid upstream validation errors",
|
"e.g. Clean tool parameters to avoid upstream validation errors": "e.g. Clean tool parameters to avoid upstream validation errors",
|
||||||
"e.g. example.com": "e.g. example.com",
|
"e.g. example.com": "e.g. example.com",
|
||||||
|
"e.g. gpt-4o, gemini-2.5-flash": "e.g. gpt-4o, gemini-2.5-flash",
|
||||||
"e.g. llama3.1:8b": "e.g. llama3.1:8b",
|
"e.g. llama3.1:8b": "e.g. llama3.1:8b",
|
||||||
"e.g. My GitLab": "e.g. My GitLab",
|
"e.g. My GitLab": "e.g. My GitLab",
|
||||||
"e.g. my-gitlab": "e.g. my-gitlab",
|
"e.g. my-gitlab": "e.g. my-gitlab",
|
||||||
@@ -1717,6 +1725,7 @@
|
|||||||
"Everything configured for this group, in one place.": "Everything configured for this group, in one place.",
|
"Everything configured for this group, in one place.": "Everything configured for this group, in one place.",
|
||||||
"Exact": "Exact",
|
"Exact": "Exact",
|
||||||
"Exact Match": "Exact Match",
|
"Exact Match": "Exact Match",
|
||||||
|
"Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.",
|
||||||
"Example": "Example",
|
"Example": "Example",
|
||||||
"Example (all channels):": "Example (all channels):",
|
"Example (all channels):": "Example (all channels):",
|
||||||
"Example (specific channels):": "Example (specific channels):",
|
"Example (specific channels):": "Example (specific channels):",
|
||||||
@@ -1903,7 +1912,11 @@
|
|||||||
"Failed to update user": "Failed to update user",
|
"Failed to update user": "Failed to update user",
|
||||||
"Failure keywords": "Failure keywords",
|
"Failure keywords": "Failure keywords",
|
||||||
"Fair": "Fair",
|
"Fair": "Fair",
|
||||||
|
"Fallback": "Fallback",
|
||||||
"Fallback base URL": "Fallback base URL",
|
"Fallback base URL": "Fallback base URL",
|
||||||
|
"Fallback for remaining models": "Fallback for remaining models",
|
||||||
|
"Fallback must be last": "Fallback must be last",
|
||||||
|
"Fallback route": "Fallback route",
|
||||||
"Fallback tier": "Fallback tier",
|
"Fallback tier": "Fallback tier",
|
||||||
"FAQ": "FAQ",
|
"FAQ": "FAQ",
|
||||||
"FAQ added. Click \"Save Settings\" to apply.": "FAQ added. Click \"Save Settings\" to apply.",
|
"FAQ added. Click \"Save Settings\" to apply.": "FAQ added. Click \"Save Settings\" to apply.",
|
||||||
@@ -1941,6 +1954,7 @@
|
|||||||
"Fill Related Models": "Fill Related Models",
|
"Fill Related Models": "Fill Related Models",
|
||||||
"Fill Template": "Fill Template",
|
"Fill Template": "Fill Template",
|
||||||
"Fill Templates": "Fill Templates",
|
"Fill Templates": "Fill Templates",
|
||||||
|
"Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.",
|
||||||
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format",
|
"Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format",
|
||||||
"Filled {{count}} model(s)": "Filled {{count}} model(s)",
|
"Filled {{count}} model(s)": "Filled {{count}} model(s)",
|
||||||
"Filled {{count}} related model(s)": "Filled {{count}} related model(s)",
|
"Filled {{count}} related model(s)": "Filled {{count}} related model(s)",
|
||||||
@@ -1982,6 +1996,7 @@
|
|||||||
"First token": "First token",
|
"First token": "First token",
|
||||||
"First/Last Frame to Video": "First/Last Frame to Video",
|
"First/Last Frame to Video": "First/Last Frame to Video",
|
||||||
"Fix Abilities": "Repair Channel Consistency",
|
"Fix Abilities": "Repair Channel Consistency",
|
||||||
|
"Fix order": "Fix order",
|
||||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
|
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
|
||||||
"Fixed price": "Fixed price",
|
"Fixed price": "Fixed price",
|
||||||
"Fixed price (USD)": "Fixed price (USD)",
|
"Fixed price (USD)": "Fixed price (USD)",
|
||||||
@@ -2426,16 +2441,21 @@
|
|||||||
"Leave blank to keep the existing credential": "Leave blank to keep the existing credential",
|
"Leave blank to keep the existing credential": "Leave blank to keep the existing credential",
|
||||||
"Leave blank to keep the existing key": "Leave blank to keep the existing key",
|
"Leave blank to keep the existing key": "Leave blank to keep the existing key",
|
||||||
"Leave blank unless rotating the secret": "Leave blank unless rotating the secret",
|
"Leave blank unless rotating the secret": "Leave blank unless rotating the secret",
|
||||||
|
"Leave empty for fallback": "Leave empty for fallback",
|
||||||
"Leave empty for never expires": "Leave empty for never expires",
|
"Leave empty for never expires": "Leave empty for never expires",
|
||||||
|
"Leave empty only for the final fallback split.": "Leave empty only for the final fallback split.",
|
||||||
"Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.",
|
"Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.",
|
||||||
"Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.",
|
"Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.",
|
||||||
"Leave empty to disband the tag": "Leave empty to disband the tag",
|
"Leave empty to disband the tag": "Leave empty to disband the tag",
|
||||||
"Leave empty to keep existing key": "Leave empty to keep existing key",
|
"Leave empty to keep existing key": "Leave empty to keep existing key",
|
||||||
"Leave empty to keep unchanged": "Leave empty to keep unchanged",
|
"Leave empty to keep unchanged": "Leave empty to keep unchanged",
|
||||||
|
"Leave empty to match all models": "Leave empty to match all models",
|
||||||
"Leave empty to use account email": "Leave empty to use account email",
|
"Leave empty to use account email": "Leave empty to use account email",
|
||||||
"Leave empty to use default": "Leave empty to use default",
|
"Leave empty to use default": "Leave empty to use default",
|
||||||
"Leave empty to use system temp directory": "Leave empty to use system temp directory",
|
"Leave empty to use system temp directory": "Leave empty to use system temp directory",
|
||||||
"Leave empty to use username": "Leave empty to use username",
|
"Leave empty to use username": "Leave empty to use username",
|
||||||
|
"Leave the final split empty as the fallback for models not matched above.": "Leave the final split empty as the fallback for models not matched above.",
|
||||||
|
"Leave this empty only for the final fallback split; it catches client models not matched above.": "Leave this empty only for the final fallback split; it catches client models not matched above.",
|
||||||
"Left to Right": "Left to Right",
|
"Left to Right": "Left to Right",
|
||||||
"Legacy Format (JSON Object)": "Legacy Format (JSON Object)",
|
"Legacy Format (JSON Object)": "Legacy Format (JSON Object)",
|
||||||
"Legacy format must be a JSON object": "Legacy format must be a JSON object",
|
"Legacy format must be a JSON object": "Legacy format must be a JSON object",
|
||||||
@@ -2482,6 +2502,9 @@
|
|||||||
"Loading...": "Loading...",
|
"Loading...": "Loading...",
|
||||||
"Local": "Local",
|
"Local": "Local",
|
||||||
"Local Billing": "Local Billing",
|
"Local Billing": "Local Billing",
|
||||||
|
"Local Estimate (billing-usage-anthropic)": "Local Estimate (billing-usage-anthropic)",
|
||||||
|
"Local Estimate (billing-usage-gemini)": "Local Estimate (billing-usage-gemini)",
|
||||||
|
"Local Estimate (billing-usage-openai)": "Local Estimate (billing-usage-openai)",
|
||||||
"Local models": "Local models",
|
"Local models": "Local models",
|
||||||
"Locations": "Locations",
|
"Locations": "Locations",
|
||||||
"Locked": "Locked",
|
"Locked": "Locked",
|
||||||
@@ -2548,7 +2571,9 @@
|
|||||||
"Match Value": "Match Value",
|
"Match Value": "Match Value",
|
||||||
"Match Value (optional)": "Match Value (optional)",
|
"Match Value (optional)": "Match Value (optional)",
|
||||||
"Matched": "Matched",
|
"Matched": "Matched",
|
||||||
|
"Matched models": "Matched models",
|
||||||
"Matched Tier": "Matched Tier",
|
"Matched Tier": "Matched Tier",
|
||||||
|
"Matches models not claimed by earlier splits.": "Matches models not claimed by earlier splits.",
|
||||||
"Matching Rules": "Matching Rules",
|
"Matching Rules": "Matching Rules",
|
||||||
"Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)",
|
"Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)",
|
||||||
"Max Entries": "Max Entries",
|
"Max Entries": "Max Entries",
|
||||||
@@ -2662,12 +2687,15 @@
|
|||||||
"Model ratios reset successfully": "Model ratios reset successfully",
|
"Model ratios reset successfully": "Model ratios reset successfully",
|
||||||
"Model Regex": "Model Regex",
|
"Model Regex": "Model Regex",
|
||||||
"Model Regex (one per line)": "Model Regex (one per line)",
|
"Model Regex (one per line)": "Model Regex (one per line)",
|
||||||
|
"Model regex cannot be empty": "Model regex cannot be empty",
|
||||||
|
"Model scope": "Model scope",
|
||||||
"Model selected": "Model selected",
|
"Model selected": "Model selected",
|
||||||
"Model Square": "Model Square",
|
"Model Square": "Model Square",
|
||||||
"Model Tags": "Model Tags",
|
"Model Tags": "Model Tags",
|
||||||
"Model to use for testing": "Model to use for testing",
|
"Model to use for testing": "Model to use for testing",
|
||||||
"Model to use when testing channel connectivity": "Model to use when testing channel connectivity",
|
"Model to use when testing channel connectivity": "Model to use when testing channel connectivity",
|
||||||
"Model Version *": "Model Version *",
|
"Model Version *": "Model Version *",
|
||||||
|
"Model-scoped only": "Model-scoped only",
|
||||||
"model(s) selected out of": "model(s) selected out of",
|
"model(s) selected out of": "model(s) selected out of",
|
||||||
"model(s)? This action cannot be undone.": "model(s)? This action cannot be undone.",
|
"model(s)? This action cannot be undone.": "model(s)? This action cannot be undone.",
|
||||||
"models": "models",
|
"models": "models",
|
||||||
@@ -2714,9 +2742,12 @@
|
|||||||
"Move": "Move",
|
"Move": "Move",
|
||||||
"Move a request header": "Move a request header",
|
"Move a request header": "Move a request header",
|
||||||
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
|
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
|
||||||
|
"Move fallback to end": "Move fallback to end",
|
||||||
"Move Field": "Move Field",
|
"Move Field": "Move Field",
|
||||||
"Move Header": "Move Header",
|
"Move Header": "Move Header",
|
||||||
"Move Request Header": "Move Request Header",
|
"Move Request Header": "Move Request Header",
|
||||||
|
"Move route down": "Move route down",
|
||||||
|
"Move route up": "Move route up",
|
||||||
"Move source field to target field": "Move source field to target field",
|
"Move source field to target field": "Move source field to target field",
|
||||||
"ms": "ms",
|
"ms": "ms",
|
||||||
"Multi-key channel: Keys will be": "Multi-key channel: Keys will be",
|
"Multi-key channel: Keys will be": "Multi-key channel: Keys will be",
|
||||||
@@ -3050,6 +3081,7 @@
|
|||||||
"Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.",
|
"Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.",
|
||||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
|
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
|
||||||
"Only Mine": "Only Mine",
|
"Only Mine": "Only Mine",
|
||||||
|
"Only one catch-all route is allowed for the same incoming path": "Only one catch-all route is allowed for the same incoming path",
|
||||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
|
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
|
||||||
"Only successful requests": "Only successful requests",
|
"Only successful requests": "Only successful requests",
|
||||||
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
|
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
|
||||||
@@ -3088,6 +3120,7 @@
|
|||||||
"OpenAI Rerank": "OpenAI Rerank",
|
"OpenAI Rerank": "OpenAI Rerank",
|
||||||
"OpenAI Responses": "OpenAI Responses",
|
"OpenAI Responses": "OpenAI Responses",
|
||||||
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
"OpenAI Responses Compact": "OpenAI Responses Compact",
|
||||||
|
"OpenAI Responses to Gemini Generate Content": "OpenAI Responses to Gemini Generate Content",
|
||||||
"OpenAI Responses to OpenAI Chat": "OpenAI Responses to OpenAI Chat",
|
"OpenAI Responses to OpenAI Chat": "OpenAI Responses to OpenAI Chat",
|
||||||
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
|
"OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.",
|
||||||
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
|
"OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.",
|
||||||
@@ -3835,9 +3868,15 @@
|
|||||||
"Route": "Route",
|
"Route": "Route",
|
||||||
"Route active": "Route active",
|
"Route active": "Route active",
|
||||||
"Route Description": "Route Description",
|
"Route Description": "Route Description",
|
||||||
|
"Route group": "Route group",
|
||||||
"Route is required": "Route is required",
|
"Route is required": "Route is required",
|
||||||
|
"Route models must be unique for the same incoming path": "Route models must be unique for the same incoming path",
|
||||||
"Route, auth, and balance check in one place": "Route, auth, and balance check in one place",
|
"Route, auth, and balance check in one place": "Route, auth, and balance check in one place",
|
||||||
"Routes": "Routes",
|
"Routes": "Routes",
|
||||||
|
"Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.",
|
||||||
|
"Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.",
|
||||||
|
"Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.",
|
||||||
|
"Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.",
|
||||||
"Routing & Overrides": "Routing & Overrides",
|
"Routing & Overrides": "Routing & Overrides",
|
||||||
"Routing Reliability": "Routing Reliability",
|
"Routing Reliability": "Routing Reliability",
|
||||||
"Routing Strategy": "Routing Strategy",
|
"Routing Strategy": "Routing Strategy",
|
||||||
@@ -3862,6 +3901,7 @@
|
|||||||
"Rules": "Rules",
|
"Rules": "Rules",
|
||||||
"Rules JSON": "Rules JSON",
|
"Rules JSON": "Rules JSON",
|
||||||
"Rules JSON must be an array": "Rules JSON must be an array",
|
"Rules JSON must be an array": "Rules JSON must be an array",
|
||||||
|
"Rules match the original model value from the client request body.": "Rules match the original model value from the client request body.",
|
||||||
"Run GC": "Run GC",
|
"Run GC": "Run GC",
|
||||||
"Run tests for the selected models": "Run tests for the selected models",
|
"Run tests for the selected models": "Run tests for the selected models",
|
||||||
"running": "running",
|
"running": "running",
|
||||||
@@ -4057,6 +4097,7 @@
|
|||||||
"Sensitive Words": "Sensitive Words",
|
"Sensitive Words": "Sensitive Words",
|
||||||
"Sent the API key to FluentRead.": "Sent the API key to FluentRead.",
|
"Sent the API key to FluentRead.": "Sent the API key to FluentRead.",
|
||||||
"Separate image/audio prices are enabled.": "Separate image/audio prices are enabled.",
|
"Separate image/audio prices are enabled.": "Separate image/audio prices are enabled.",
|
||||||
|
"Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.",
|
||||||
"Serve multiple users or teams with billing and quota control.": "Serve multiple users or teams with billing and quota control.",
|
"Serve multiple users or teams with billing and quota control.": "Serve multiple users or teams with billing and quota control.",
|
||||||
"Server Address": "Server Address",
|
"Server Address": "Server Address",
|
||||||
"Server IP": "Server IP",
|
"Server IP": "Server IP",
|
||||||
@@ -4470,6 +4511,7 @@
|
|||||||
"This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.",
|
"This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.",
|
||||||
"This feature is experimental. Configuration format and behavior may change.": "This feature is experimental. Configuration format and behavior may change.",
|
"This feature is experimental. Configuration format and behavior may change.": "This feature is experimental. Configuration format and behavior may change.",
|
||||||
"This feature requires server-side WeChat configuration": "This feature requires server-side WeChat configuration",
|
"This feature requires server-side WeChat configuration": "This feature requires server-side WeChat configuration",
|
||||||
|
"This field does not support wildcards or regular expressions.": "This field does not support wildcards or regular expressions.",
|
||||||
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.",
|
"This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.",
|
||||||
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.",
|
"This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.",
|
||||||
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.",
|
"This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.",
|
||||||
@@ -4536,9 +4578,13 @@
|
|||||||
"Timing": "Timing",
|
"Timing": "Timing",
|
||||||
"Tip": "Tip",
|
"Tip": "Tip",
|
||||||
"to access this resource.": "to access this resource.",
|
"to access this resource.": "to access this resource.",
|
||||||
|
"To Anthropic Messages": "To Anthropic Messages",
|
||||||
"to confirm": "to confirm",
|
"to confirm": "to confirm",
|
||||||
|
"To Gemini Generate Content": "To Gemini Generate Content",
|
||||||
"To Lower": "To Lower",
|
"To Lower": "To Lower",
|
||||||
"To Lowercase": "To Lowercase",
|
"To Lowercase": "To Lowercase",
|
||||||
|
"To OpenAI Chat": "To OpenAI Chat",
|
||||||
|
"To OpenAI Responses": "To OpenAI Responses",
|
||||||
"to override billing when a user in one group uses a token of another group.": "to override billing when a user in one group uses a token of another group.",
|
"to override billing when a user in one group uses a token of another group.": "to override billing when a user in one group uses a token of another group.",
|
||||||
"to the Models list so users can use them before the mapping sends traffic upstream.": "to the Models list so users can use them before the mapping sends traffic upstream.",
|
"to the Models list so users can use them before the mapping sends traffic upstream.": "to the Models list so users can use them before the mapping sends traffic upstream.",
|
||||||
"To Upper": "To Upper",
|
"To Upper": "To Upper",
|
||||||
@@ -4786,6 +4832,12 @@
|
|||||||
"Upstream ratios fetched successfully": "Upstream ratios fetched successfully",
|
"Upstream ratios fetched successfully": "Upstream ratios fetched successfully",
|
||||||
"Upstream Request ID": "Upstream Request ID",
|
"Upstream Request ID": "Upstream Request ID",
|
||||||
"Upstream Response": "Upstream Response",
|
"Upstream Response": "Upstream Response",
|
||||||
|
"Upstream Response (billing-usage-anthropic-estimated)": "Upstream Response (billing-usage-anthropic-estimated)",
|
||||||
|
"Upstream Response (billing-usage-anthropic)": "Upstream Response (billing-usage-anthropic)",
|
||||||
|
"Upstream Response (billing-usage-gemini-estimated)": "Upstream Response (billing-usage-gemini-estimated)",
|
||||||
|
"Upstream Response (billing-usage-gemini)": "Upstream Response (billing-usage-gemini)",
|
||||||
|
"Upstream Response (billing-usage-openai-estimated)": "Upstream Response (billing-usage-openai-estimated)",
|
||||||
|
"Upstream Response (billing-usage-openai)": "Upstream Response (billing-usage-openai)",
|
||||||
"upstream services integrated": "upstream services integrated",
|
"upstream services integrated": "upstream services integrated",
|
||||||
"Upstream Updates": "Upstream Updates",
|
"Upstream Updates": "Upstream Updates",
|
||||||
"Upstream URL": "Upstream URL",
|
"Upstream URL": "Upstream URL",
|
||||||
@@ -4819,6 +4871,8 @@
|
|||||||
"Use authenticator code": "Use authenticator code",
|
"Use authenticator code": "Use authenticator code",
|
||||||
"Use backup code": "Use backup code",
|
"Use backup code": "Use backup code",
|
||||||
"Use disk cache when request body exceeds this size": "Use disk cache when request body exceeds this size",
|
"Use disk cache when request body exceeds this size": "Use disk cache when request body exceeds this size",
|
||||||
|
"Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.",
|
||||||
|
"Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.",
|
||||||
"Use external tools to extend capabilities": "Use external tools to extend capabilities",
|
"Use external tools to extend capabilities": "Use external tools to extend capabilities",
|
||||||
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.",
|
"Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.",
|
||||||
"Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.",
|
"Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user