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:
Calcium-Ion
2026-07-11 20:44:12 +08:00
committed by GitHub
parent 1250fb2eb5
commit c36418c863
106 changed files with 13345 additions and 4307 deletions
+7 -7
View File
@@ -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 }}
+9 -9
View File
@@ -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: |
+8 -8
View File
@@ -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/*
+1 -1
View File
@@ -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
+12 -12
View File
@@ -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: |
+88 -1
View File
@@ -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{
+217
View File
@@ -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
}
+89
View File
@@ -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
View File
@@ -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
} }
+347 -2
View File
@@ -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"))
}
+1
View File
@@ -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
View File
@@ -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 {
+34
View File
@@ -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"`)
}
+7 -6
View File
@@ -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"`
+4 -3
View File
@@ -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 {
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+294
View File
@@ -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

-111
View File
@@ -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

+69 -52
View File
@@ -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
+350 -18
View File
@@ -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,
}, },
+5 -1
View File
@@ -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}
} }
+3 -3
View File
@@ -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)
+5 -1
View File
@@ -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
} }
+6 -1
View File
@@ -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) {
+20 -769
View File
@@ -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
}
+6 -6
View File
@@ -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)
+13 -15
View File
@@ -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) {
+2 -2
View File
@@ -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))
}
+39 -12
View File
@@ -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
} }
+10 -3
View File
@@ -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
+104 -50
View File
@@ -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)
} }
} }
+38 -4
View File
@@ -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 {
+11 -4
View File
@@ -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 -8
View File
@@ -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
} }
+12 -4
View File
@@ -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 {
+6 -2
View File
@@ -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
+5 -1
View File
@@ -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 {
+62
View File
@@ -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")
+45
View File
@@ -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 "}`)
+2 -2
View File
@@ -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)
} }
} }
+205
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -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("![image](data:")
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("![image](data:")
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
}
@@ -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,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,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, &parallelToolCalls); err != nil {
return nil
}
return &parallelToolCalls
}
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"
}
}
@@ -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 {
@@ -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,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,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,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
}
+9
View File
@@ -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)
}
+57
View File
@@ -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)
}
+499
View File
@@ -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
}
+141
View File
@@ -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)
})
}
}
+54
View File
@@ -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
View File
@@ -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)
+166
View File
@@ -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{
@@ -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
View File
@@ -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
View File
@@ -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'
@@ -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
View File
@@ -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
+54
View File
@@ -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