diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 7aff7de85f7b..7097fc3b1c27 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check out
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
ref: ${{ github.event.inputs.tag || github.ref }}
@@ -59,23 +59,23 @@ jobs:
echo "Building tag: ${TAG} for ${{ matrix.arch }}"
- 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
- uses: docker/login-action@v3
+ uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (labels)
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: calciumion/new-api
- name: Build & push
id: build
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -90,7 +90,7 @@ jobs:
sbom: true
- name: Install cosign
- uses: sigstore/cosign-installer@v3
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign image with cosign
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
- name: Log in to Docker Hub
- uses: docker/login-action@v3
+ uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
diff --git a/.github/workflows/docker-image-branch.yml b/.github/workflows/docker-image-branch.yml
index 8b7fd07381ad..b5222468cdc6 100644
--- a/.github/workflows/docker-image-branch.yml
+++ b/.github/workflows/docker-image-branch.yml
@@ -21,7 +21,7 @@ jobs:
contents: read
steps:
- name: Check out branch
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
ref: ${{ inputs.branch }}
@@ -68,7 +68,7 @@ jobs:
id-token: write
steps:
- name: Check out branch
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
ref: ${{ needs.prepare.outputs.sha }}
@@ -79,24 +79,24 @@ jobs:
echo "Publishing version: ${{ needs.prepare.outputs.version }} for ${{ matrix.arch }}"
- 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
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
+ uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (labels)
id: meta
- uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: |
calciumion/new-api
- name: Build & push single-arch
id: build
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
platforms: ${{ matrix.platform }}
@@ -111,7 +111,7 @@ jobs:
sbom: true
- name: Install cosign
- uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign image with cosign
run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }}
@@ -133,7 +133,7 @@ jobs:
id-token: write
steps:
- name: Log in to Docker Hub
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
+ uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -153,7 +153,7 @@ jobs:
calciumion/new-api:${{ needs.prepare.outputs.version }}-arm64
- name: Install cosign
- uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
+ uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign manifests with cosign
run: |
diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml
index 20113e00fe6b..edb1817aa332 100644
--- a/.github/workflows/electron-build.yml
+++ b/.github/workflows/electron-build.yml
@@ -22,22 +22,22 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Setup Bun
- uses: oven-sh/setup-bun@v2
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '>=1.25.1'
@@ -106,7 +106,7 @@ jobs:
# - name: Upload artifacts (macOS)
# if: runner.os == 'macOS'
- # uses: actions/upload-artifact@v4
+ # uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# with:
# name: macos-build
# path: |
@@ -115,7 +115,7 @@ jobs:
- name: Upload artifacts (Windows)
if: runner.os == 'Windows'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-build
path: |
@@ -130,12 +130,12 @@ jobs:
steps:
- name: Download all artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- name: Upload to Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: |
windows-build/*
env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 2dcda35e676e..67591702b4fd 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -13,7 +13,7 @@ jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- - uses: peakoss/anti-slop@v0.2.1
+ - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
with:
max-failures: 4
require-description: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 32bdefdddd3a..6e519749794f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,14 +19,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Build Frontend (default)
@@ -48,7 +48,7 @@ jobs:
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '>=1.25.1'
- name: Build Backend (amd64)
@@ -64,7 +64,7 @@ jobs:
run: sha256sum new-api-* > checksums-linux.txt
- name: Release
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
@@ -78,14 +78,14 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Build Frontend (default)
@@ -108,7 +108,7 @@ jobs:
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '>=1.25.1'
- name: Build Backend
@@ -119,7 +119,7 @@ jobs:
run: shasum -a 256 new-api-macos-* > checksums-macos.txt
- name: Release
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
@@ -136,14 +136,14 @@ jobs:
shell: bash
steps:
- name: Checkout
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Determine Version
run: |
VERSION=$(git describe --tags)
echo "VERSION=$VERSION" >> $GITHUB_ENV
- - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Build Frontend (default)
@@ -165,7 +165,7 @@ jobs:
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Set up Go
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '>=1.25.1'
- name: Build Backend
@@ -176,7 +176,7 @@ jobs:
run: sha256sum new-api-*.exe > checksums-windows.txt
- name: Release
- uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
+ uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
if: startsWith(github.ref, 'refs/tags/')
with:
files: |
diff --git a/controller/model_list_test.go b/controller/model_list_test.go
index 3d09956bfa8f..55334b1bf43c 100644
--- a/controller/model_list_test.go
+++ b/controller/model_list_test.go
@@ -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()
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.True(t, payload.Success)
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))
for _, item := range payload.Data {
ids[item.Id] = struct{}{}
@@ -255,6 +271,77 @@ func TestListModelsIncludesTieredBillingModel(t *testing.T) {
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) {
withSelfUseModeDisabled(t)
withTieredBillingConfig(t, map[string]string{
diff --git a/dto/billing_usage.go b/dto/billing_usage.go
new file mode 100644
index 000000000000..4c8f3f51a9ac
--- /dev/null
+++ b/dto/billing_usage.go
@@ -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
+}
diff --git a/dto/billing_usage_test.go b/dto/billing_usage_test.go
new file mode 100644
index 000000000000..bc5e969b4045
--- /dev/null
+++ b/dto/billing_usage_test.go
@@ -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)
+}
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index dc03773998d8..dbcfd3181ae9 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -3,7 +3,11 @@ package dto
import (
"fmt"
"net/url"
+ "regexp"
"strings"
+ "sync"
+
+ "github.com/QuantumNous/new-api/constant"
)
type ChannelSettings struct {
@@ -59,13 +63,14 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
}
const (
- AdvancedCustomConverterNone = "none"
- AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions"
- AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages"
- AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses"
- AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions = "openai_responses_to_openai_chat_completions"
- AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions"
- AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content"
+ advancedCustomConverterNone = "none"
+ advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
+ advancedCustomConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
+ advancedCustomConverterOpenAIChatToOpenAIResponses = "openai_chat_completions_to_openai_responses"
+ advancedCustomConverterOpenAIResponsesToOpenAIChat = "openai_responses_to_openai_chat_completions"
+ advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
+ advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
+ advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
)
const (
@@ -82,6 +87,7 @@ type AdvancedCustomRoute struct {
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
+ Models []string `json:"models,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
}
@@ -91,7 +97,20 @@ type AdvancedCustomRouteAuth struct {
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.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
@@ -108,12 +127,133 @@ func (c *AdvancedCustomConfig) MatchPath(requestPath string) (AdvancedCustomRout
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.
func (c *AdvancedCustomConfig) SupportsPath(requestPath string) bool {
_, ok := c.MatchPath(requestPath)
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 {
if matchAdvancedCustomIncomingPathTemplate(configuredPath, requestPath) {
return true
@@ -144,13 +284,14 @@ func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
- case AdvancedCustomConverterNone,
- AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
- AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
- AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
- AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
- AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
- AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
+ case advancedCustomConverterNone,
+ advancedCustomConverterClaudeMessagesToOpenAIChat,
+ advancedCustomConverterOpenAIChatToClaudeMessages,
+ advancedCustomConverterOpenAIChatToOpenAIResponses,
+ advancedCustomConverterOpenAIResponsesToOpenAIChat,
+ advancedCustomConverterOpenAIResponsesToGemini,
+ advancedCustomConverterGeminiContentToOpenAIChat,
+ advancedCustomConverterOpenAIChatToGeminiContent:
return true
default:
return false
@@ -165,14 +306,14 @@ func (c *AdvancedCustomConfig) Validate() error {
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 {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
upstreamPath := strings.TrimSpace(route.UpstreamPath)
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
- route.Converter = AdvancedCustomConverterNone
+ route.Converter = advancedCustomConverterNone
}
if route.IncomingPath == "" {
@@ -184,10 +325,9 @@ func (c *AdvancedCustomConfig) Validate() error {
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
- if _, exists := seenPaths[route.IncomingPath]; exists {
- return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must be unique: %s", i, route.IncomingPath)
+ if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
+ return err
}
- seenPaths[route.IncomingPath] = struct{}{}
if upstreamPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
@@ -210,6 +350,79 @@ func (c *AdvancedCustomConfig) Validate() error {
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 {
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 {
switch converter {
- case AdvancedCustomConverterNone:
+ case advancedCustomConverterNone:
return nil
- case AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
+ case advancedCustomConverterClaudeMessagesToOpenAIChat:
if incomingPath == "/v1/messages" {
return nil
}
- case AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
- AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
- AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
+ case advancedCustomConverterOpenAIChatToClaudeMessages,
+ advancedCustomConverterOpenAIChatToOpenAIResponses,
+ advancedCustomConverterOpenAIChatToGeminiContent:
if incomingPath == "/v1/chat/completions" {
return nil
}
- case AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
+ case advancedCustomConverterOpenAIResponsesToOpenAIChat:
+ if incomingPath == "/v1/responses" {
+ return nil
+ }
+ case advancedCustomConverterOpenAIResponsesToGemini:
if incomingPath == "/v1/responses" {
return nil
}
- case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
+ case advancedCustomConverterGeminiContentToOpenAIChat:
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
return nil
}
diff --git a/dto/channel_settings_test.go b/dto/channel_settings_test.go
index 92ae80452e7c..4d6ccc0fec0b 100644
--- a/dto/channel_settings_test.go
+++ b/dto/channel_settings_test.go
@@ -1,8 +1,10 @@
package dto
import (
+ "regexp"
"testing"
+ "github.com/QuantumNous/new-api/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -13,12 +15,23 @@ func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
{
IncomingPath: "/v1/responses",
UpstreamPath: "/v1/chat/completions",
- Converter: AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
+ Converter: advancedCustomConverterOpenAIResponsesToOpenAIChat,
},
},
}
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 {
name string
incomingPath string
@@ -34,7 +47,7 @@ func TestAdvancedCustomValidateResponsesToChatConverterPath(t *testing.T) {
{
IncomingPath: tt.incomingPath,
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"))
+}
diff --git a/dto/claude.go b/dto/claude.go
index d7fed412aaa9..0c552b4b2815 100644
--- a/dto/claude.go
+++ b/dto/claude.go
@@ -564,6 +564,7 @@ type ClaudeUsage struct {
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
ClaudeCacheCreation1hTokens int `json:"claude_cache_creation_1_h_tokens"`
ServerToolUse *ClaudeServerToolUse `json:"server_tool_use,omitempty"`
+ BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
}
type ClaudeCacheCreationUsage struct {
diff --git a/dto/gemini.go b/dto/gemini.go
index 489ebea534b4..e9bbb7f2a647 100644
--- a/dto/gemini.go
+++ b/dto/gemini.go
@@ -44,9 +44,9 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
}
type ToolConfig struct {
- FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
- RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
- IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
+ FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
+ RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
+ IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
}
type FunctionCallingConfig struct {
@@ -455,9 +455,46 @@ type GeminiChatPromptFeedback struct {
}
type GeminiChatResponse struct {
- Candidates []GeminiChatCandidate `json:"candidates"`
- PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
- UsageMetadata GeminiUsageMetadata `json:"usageMetadata"`
+ Candidates []GeminiChatCandidate `json:"candidates"`
+ PromptFeedback *GeminiChatPromptFeedback `json:"promptFeedback,omitempty"`
+ 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 {
@@ -470,6 +507,7 @@ type GeminiUsageMetadata struct {
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
ToolUsePromptTokensDetails []GeminiPromptTokensDetails `json:"toolUsePromptTokensDetails"`
CandidatesTokensDetails []GeminiPromptTokensDetails `json:"candidatesTokensDetails"`
+ BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
}
type GeminiPromptTokensDetails struct {
diff --git a/dto/gemini_response_test.go b/dto/gemini_response_test.go
new file mode 100644
index 000000000000..c12994bbdc2e
--- /dev/null
+++ b/dto/gemini_response_test.go
@@ -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"`)
+}
diff --git a/dto/openai_response.go b/dto/openai_response.go
index e503f5f91c69..90416e35a67a 100644
--- a/dto/openai_response.go
+++ b/dto/openai_response.go
@@ -221,12 +221,13 @@ type CompletionsStreamResponse struct {
}
type Usage struct {
- PromptTokens int `json:"prompt_tokens"`
- CompletionTokens int `json:"completion_tokens"`
- TotalTokens int `json:"total_tokens"`
- PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
- UsageSemantic string `json:"usage_semantic,omitempty"`
- UsageSource string `json:"usage_source,omitempty"`
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
+ UsageSemantic string `json:"usage_semantic,omitempty"`
+ UsageSource string `json:"usage_source,omitempty"`
+ BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
diff --git a/main.go b/main.go
index 888484a4085c..770ea156ba86 100644
--- a/main.go
+++ b/main.go
@@ -102,6 +102,10 @@ func main() {
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)
@@ -330,9 +334,6 @@ func InitResources() error {
// 清理旧的磁盘缓存文件
common.CleanupOldCacheFiles()
- // 初始化模型
- model.GetPricing()
-
// Initialize SQL Database
err = model.InitLogDB()
if err != nil {
diff --git a/middleware/distributor.go b/middleware/distributor.go
index cf5caa06d513..4234011c9f7c 100644
--- a/middleware/distributor.go
+++ b/middleware/distributor.go
@@ -105,7 +105,7 @@ func Distribute() func(c *gin.Context) {
affinityUsable := false
preferred, err := model.CacheGetChannel(preferredChannelID)
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" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
@@ -172,7 +172,7 @@ func Distribute() func(c *gin.Context) {
// channelSupportsRequestPath reports whether a channel can serve the request path.
// 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.
-func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool {
+func channelSupportsRequestPath(channel *model.Channel, requestPath string, requestModel string) bool {
if channel == nil {
return false
}
@@ -180,7 +180,7 @@ func channelSupportsRequestPath(channel *model.Channel, requestPath string) bool
return true
}
config := channel.GetOtherSettings().AdvancedCustom
- return config != nil && config.SupportsPath(requestPath)
+ return config != nil && config.SupportsPathForModel(requestPath, requestModel)
}
// getModelFromRequest 从请求中读取模型信息
diff --git a/model/ability.go b/model/ability.go
index c31fbc69479c..e67b28301e02 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -121,7 +121,7 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
if err != nil {
return nil, err
}
- abilities = filterAbilitiesByRequestPath(abilities, requestPath)
+ abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
@@ -146,11 +146,12 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
return &channel, err
}
-// filterAbilitiesByRequestPath restricts candidates by request path for the DB
-// (non-memory-cache) selection path. Only Advanced Custom (type 58) channels are
-// path-checked: kept only when one of their routes matches requestPath; all other
-// channel types always pass. When requestPath is empty, filtering is skipped.
-func filterAbilitiesByRequestPath(abilities []Ability, requestPath string) []Ability {
+// filterAbilitiesByRequestPathAndModel restricts candidates by request path and
+// model for the DB (non-memory-cache) selection path. Only Advanced Custom
+// (type 58) channels are path-checked: kept only when one of their routes matches
+// requestPath and model; all other channel types always pass. When requestPath is
+// empty, filtering is skipped.
+func filterAbilitiesByRequestPathAndModel(abilities []Ability, requestPath string, model string) []Ability {
if requestPath == "" || len(abilities) == 0 {
return abilities
}
@@ -185,7 +186,7 @@ func filterAbilitiesByRequestPath(abilities []Ability, requestPath string) []Abi
filtered = append(filtered, ability)
continue
}
- if config != nil && config.SupportsPath(requestPath) {
+ if config != nil && config.SupportsPathForModel(requestPath, model) {
filtered = append(filtered, ability)
}
}
diff --git a/model/channel_cache.go b/model/channel_cache.go
index 8ad5d141db39..81923017d79c 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -25,6 +25,7 @@ var channelSyncLock sync.RWMutex
func InitChannelCache() {
if !common.MemoryCacheEnabled {
+ InvalidatePricingCache()
return
}
newChannelId2channel := make(map[int]*Channel)
@@ -94,6 +95,11 @@ func InitChannelCache() {
channelsIDM = newChannelId2channel
channel2advancedCustomConfig = newChannel2advancedCustomConfig
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")
}
@@ -115,12 +121,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
defer channelSyncLock.RUnlock()
// 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 len(channels) == 0 {
normalizedModel := ratio_setting.FormatMatchingModelName(model)
- channels = filterChannelsByRequestPath(group2model2channels[group][normalizedModel], requestPath)
+ channels = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, model)
}
if len(channels) == 0 {
@@ -202,12 +208,12 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
return nil, errors.New("channel not found")
}
-// filterChannelsByRequestPath restricts candidates by request path. Only Advanced
-// Custom (type 58) channels are path-checked: they are kept only when one of their
-// configured routes matches requestPath. All other channel types always pass.
-// When requestPath is empty (non-relay callers) filtering is skipped.
+// filterChannelsByRequestPathAndModel restricts candidates by request path and
+// model. Only Advanced Custom (type 58) channels are path-checked: they are kept
+// only when one of their configured routes matches requestPath and model. All
+// other channel types always pass. When requestPath is empty, filtering is skipped.
// 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 {
return channels
}
@@ -223,7 +229,7 @@ func filterChannelsByRequestPath(channels []int, requestPath string) []int {
filtered = append(filtered, channelId)
continue
}
- if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPath(requestPath) {
+ if config := channel2advancedCustomConfig[channelId]; config != nil && config.SupportsPathForModel(requestPath, model) {
filtered = append(filtered, channelId)
}
}
@@ -292,8 +298,8 @@ func CacheUpdateChannel(channel *Channel) {
return
}
channelSyncLock.Lock()
- defer channelSyncLock.Unlock()
if channel == nil {
+ channelSyncLock.Unlock()
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)
}
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)
+ // 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()
}
diff --git a/model/pricing.go b/model/pricing.go
index b9574a388587..440e1e0999b9 100644
--- a/model/pricing.go
+++ b/model/pricing.go
@@ -1,7 +1,6 @@
package model
import (
- "encoding/json"
"fmt"
"strings"
@@ -10,6 +9,7 @@ import (
"github.com/QuantumNous/new-api/common"
"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/ratio_setting"
"github.com/QuantumNous/new-api/types"
@@ -107,6 +107,76 @@ func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
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() {
//modelRatios := common.GetModelRatios()
enableAbilities, err := GetAllEnableAbilityWithChannels()
@@ -201,11 +271,12 @@ func updatePricing() {
//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)
+ advancedCustomConfigs := loadPricingAdvancedCustomConfigs(enableAbilities)
// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
- channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
+ channelTypes := getPricingEndpointTypesForAbility(ability, advancedCustomConfigs)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
@@ -214,20 +285,18 @@ func updatePricing() {
modelSupportEndpointsStr[ability.Model] = endpoints
}
- // 再补充模型自定义端点:若配置有效则替换默认端点,不做合并
+ // 再补充模型自定义端点:若配置有效则追加到已有推断,不再裁剪渠道真实能力
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
- if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
- endpoints := make([]string, 0, len(raw))
+ if err := common.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
+ endpoints := modelSupportEndpointsStr[modelName]
for k, v := range raw {
switch v.(type) {
case string, map[string]interface{}:
- if !common.StringsContains(endpoints, k) {
- endpoints = append(endpoints, k)
- }
+ endpoints = appendPricingEndpoint(endpoints, k)
}
}
if len(endpoints) > 0 {
@@ -264,7 +333,7 @@ func updatePricing() {
continue
}
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 {
switch val := v.(type) {
case string:
diff --git a/model/pricing_endpoint_test.go b/model/pricing_endpoint_test.go
new file mode 100644
index 000000000000..eeca35e6516f
--- /dev/null
+++ b/model/pricing_endpoint_test.go
@@ -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])
+}
diff --git a/output/posters/newapi-40k-stars-light.png b/output/posters/newapi-40k-stars-light.png
deleted file mode 100644
index c13fa2de5a2e..000000000000
Binary files a/output/posters/newapi-40k-stars-light.png and /dev/null differ
diff --git a/output/posters/newapi-40k-stars-light.svg b/output/posters/newapi-40k-stars-light.svg
deleted file mode 100644
index 965b71e59a3b..000000000000
--- a/output/posters/newapi-40k-stars-light.svg
+++ /dev/null
@@ -1,111 +0,0 @@
-
diff --git a/relay/channel/advancedcustom/adaptor.go b/relay/channel/advancedcustom/adaptor.go
index b7744fb38420..8f6a71ddef00 100644
--- a/relay/channel/advancedcustom/adaptor.go
+++ b/relay/channel/advancedcustom/adaptor.go
@@ -17,6 +17,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/service/relayconvert"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -48,20 +49,19 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if err != nil {
return nil, err
}
- if converter == dto.AdvancedCustomConverterNone {
+ if converter == relayconvert.ConverterNone {
return a.convertOpenAICompatibleRequest(c, info, request)
}
switch converter {
- case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages:
- return a.claudeAdaptor.ConvertOpenAIRequest(c, info, request)
- case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses:
- if request == nil {
- return nil, errors.New("request is nil")
+ case relayconvert.ConverterOpenAIChatToClaudeMessages,
+ relayconvert.ConverterOpenAIChatToOpenAIResponses,
+ relayconvert.ConverterOpenAIChatToGeminiContent:
+ result, err := service.ConvertRequestByID(c, info, converter, request)
+ if err != nil {
+ return nil, err
}
- return service.ChatCompletionsRequestToResponsesRequest(request)
- case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
- return a.geminiAdaptor.ConvertOpenAIRequest(c, info, request)
+ return result.Value, nil
default:
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 {
- case dto.AdvancedCustomConverterNone:
+ case relayconvert.ConverterNone:
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
- case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
- return a.convertClaudeToOpenAICompatibleRequest(c, info, request)
+ case relayconvert.ConverterClaudeMessagesToOpenAIChat:
+ 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:
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 {
- case dto.AdvancedCustomConverterNone:
+ case relayconvert.ConverterNone:
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
- case dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
- return a.convertGeminiToOpenAICompatibleRequest(c, info, request)
+ case relayconvert.ConverterGeminiContentToOpenAIChat:
+ 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:
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
}
switch converter {
- case dto.AdvancedCustomConverterNone:
+ case relayconvert.ConverterNone:
return a.convertOpenAICompatibleResponsesRequest(c, info, request)
- case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
- chatReq, err := service.ResponsesRequestToChatCompletionsRequest(&request)
+ case relayconvert.ConverterOpenAIResponsesToOpenAIChat:
+ 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)
+ case relayconvert.ConverterOpenAIResponsesToGemini:
+ result, err := service.ConvertRequestByID(c, info, converter, request)
if err != nil {
return nil, err
}
- return a.convertOpenAICompatibleRequest(c, info, chatReq)
+ geminiRequest, ok := result.Value.(*dto.GeminiChatRequest)
+ if !ok {
+ return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
+ }
+ return geminiRequest, nil
default:
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 {
return nil, err
}
- if converter != dto.AdvancedCustomConverterNone {
+ if converter != relayconvert.ConverterNone {
return nil, fmt.Errorf("converter %q does not support embedding requests", converter)
}
return a.convertOpenAICompatibleEmbeddingRequest(c, info, request)
@@ -134,7 +164,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
if err != nil {
return nil, err
}
- if converter != dto.AdvancedCustomConverterNone {
+ if converter != relayconvert.ConverterNone {
return nil, fmt.Errorf("converter %q does not support audio requests", converter)
}
return a.convertOpenAICompatibleAudioRequest(c, info, request)
@@ -145,7 +175,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
if err != nil {
return nil, err
}
- if converter != dto.AdvancedCustomConverterNone {
+ if converter != relayconvert.ConverterNone {
return nil, fmt.Errorf("converter %q does not support image requests", converter)
}
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 {
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")
}
@@ -215,21 +245,23 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
}
switch a.converter {
- case dto.AdvancedCustomConverterNone:
+ case relayconvert.ConverterNone:
return a.doNativeResponse(c, resp, info)
- case dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
- dto.AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
+ case relayconvert.ConverterClaudeMessagesToOpenAIChat,
+ relayconvert.ConverterGeminiContentToOpenAIChat:
return a.openaiAdaptor.DoResponse(c, resp, info)
- case dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages:
+ case relayconvert.ConverterOpenAIChatToClaudeMessages:
return a.claudeAdaptor.DoResponse(c, resp, info)
- case dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
+ case relayconvert.ConverterOpenAIChatToGeminiContent:
+ return a.geminiAdaptor.DoResponse(c, resp, info)
+ case relayconvert.ConverterOpenAIResponsesToGemini:
return a.geminiAdaptor.DoResponse(c, resp, info)
- case dto.AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses:
+ case relayconvert.ConverterOpenAIChatToOpenAIResponses:
if info.IsStream {
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
}
return openai.OaiResponsesToChatHandler(c, info, resp)
- case dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions:
+ case relayconvert.ConverterOpenAIResponsesToOpenAIChat:
if info.IsStream {
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)
- route, ok := config.MatchPath(incomingPath)
+ route, ok := config.MatchPathForModel(incomingPath, info.OriginModelName)
if ok {
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
- route.Converter = dto.AdvancedCustomConverterNone
+ route.Converter = relayconvert.ConverterNone
}
a.route = route
a.converter = route.Converter
a.resolved = true
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 {
@@ -391,7 +423,8 @@ func applyUpstreamPathTemplate(upstreamPath string, info *relaycommon.RelayInfo)
func shouldUseGeminiStreamURL(converter string, info *relaycommon.RelayInfo) bool {
return info != nil &&
info.IsStream &&
- converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent
+ (converter == relayconvert.ConverterOpenAIChatToGeminiContent ||
+ converter == relayconvert.ConverterOpenAIResponsesToGemini)
}
func useGeminiStreamGenerateContentURL(parsedURL *url.URL) {
@@ -406,8 +439,8 @@ func useGeminiStreamGenerateContentURL(parsedURL *url.URL) {
}
func shouldApplyClaudeHeaders(converter string, info *relaycommon.RelayInfo) bool {
- return converter == dto.AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages ||
- (converter == dto.AdvancedCustomConverterNone && info != nil && info.RelayFormat == types.RelayFormatClaude)
+ return converter == relayconvert.ConverterOpenAIChatToClaudeMessages ||
+ (converter == relayconvert.ConverterNone && info != nil && info.RelayFormat == types.RelayFormatClaude)
}
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
}
-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) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
diff --git a/relay/channel/advancedcustom/adaptor_test.go b/relay/channel/advancedcustom/adaptor_test.go
index 4cd8c92ca6d6..ef71301e6f0b 100644
--- a/relay/channel/advancedcustom/adaptor_test.go
+++ b/relay/channel/advancedcustom/adaptor_test.go
@@ -1,6 +1,8 @@
package advancedcustom
import (
+ "bytes"
+ "io"
"net/http"
"net/http/httptest"
"net/url"
@@ -11,6 +13,8 @@ import (
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
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/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -24,7 +28,7 @@ func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
{
IncomingPath: "/v1/messages",
UpstreamPath: "https://upstream.example/v1/chat/completions?existing=1",
- Converter: dto.AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
+ Converter: relayconvert.ConverterClaudeMessagesToOpenAIChat,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "api_key",
@@ -54,7 +58,7 @@ func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) {
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/proxy/v1/chat/completions?existing=1",
- Converter: dto.AdvancedCustomConverterNone,
+ Converter: relayconvert.ConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "api_key",
@@ -84,7 +88,7 @@ func TestAdaptorReturnsErrorWhenUpstreamPathNeedsMissingBaseURL(t *testing.T) {
{
IncomingPath: "/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",
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",
UpstreamPath: "https://upstream.example/v1/chat/completions",
- Converter: dto.AdvancedCustomConverterNone,
+ Converter: relayconvert.ConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeHeader,
Name: "x-api-key",
@@ -144,7 +148,7 @@ func TestAdaptorSetupRequestHeaderAddsClaudeDefaultHeaders(t *testing.T) {
{
IncomingPath: "/v1/messages",
UpstreamPath: "https://api.anthropic.com/v1/messages",
- Converter: dto.AdvancedCustomConverterNone,
+ Converter: relayconvert.ConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeHeader,
Name: "x-api-key",
@@ -169,7 +173,7 @@ func TestAdaptorReturnsErrorWhenNoRouteMatchesPath(t *testing.T) {
{
IncomingPath: "/v1/messages",
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",
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
- Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent,
+ Converter: relayconvert.ConverterOpenAIChatToGeminiContent,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "key",
@@ -215,7 +219,7 @@ func TestAdaptorSwitchesGeminiGenerateContentURLForStream(t *testing.T) {
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?existing=1",
- Converter: dto.AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent,
+ Converter: relayconvert.ConverterOpenAIChatToGeminiContent,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "key",
@@ -264,7 +268,7 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) {
{
IncomingPath: "/v1beta/models/{model}:generateContent",
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",
UpstreamPath: "/v1/chat/completions",
- Converter: dto.AdvancedCustomConverterOpenAIResponsesToOpenAIChatCompletions,
+ Converter: relayconvert.ConverterOpenAIResponsesToOpenAIChat,
},
},
})
info.RelayMode = relayconstant.RelayModeResponses
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{
Model: "gpt-test",
@@ -318,15 +326,339 @@ func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
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 {
return &relaycommon.RelayInfo{
- RelayFormat: types.RelayFormatOpenAI,
- RelayMode: relayconstant.RelayModeChatCompletions,
- RequestURLPath: "/v1/chat/completions",
+ RelayFormat: types.RelayFormatOpenAI,
+ RelayMode: relayconstant.RelayModeChatCompletions,
+ RequestURLPath: "/v1/chat/completions",
+ OriginModelName: "gpt-test",
ChannelMeta: &relaycommon.ChannelMeta{
- ApiKey: "sk-test",
- ChannelBaseUrl: "https://fallback.example",
- ChannelType: constant.ChannelTypeAdvancedCustom,
+ ApiKey: "sk-test",
+ ChannelBaseUrl: "https://fallback.example",
+ ChannelType: constant.ChannelTypeAdvancedCustom,
+ UpstreamModelName: "gpt-test",
ChannelOtherSettings: dto.ChannelOtherSettings{
AdvancedCustom: config,
},
diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go
index cb3070ff367e..d2f7d219fad2 100644
--- a/relay/channel/ali/adaptor.go
+++ b/relay/channel/ali/adaptor.go
@@ -75,10 +75,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return req, nil
}
- oaiReq, err := service.ClaudeToOpenAIRequest(*req, info)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, req)
if err != nil {
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 {
oaiReq.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
}
diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go
index f260160037c0..9fae7df078d0 100644
--- a/relay/channel/api_request.go
+++ b/relay/channel/api_request.go
@@ -309,7 +309,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
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)
if err != nil {
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 {
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)
if err != nil {
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"))
targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
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
//all, err := io.ReadAll(requestBody)
diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go
index e9e5fd9137bc..8e8cdd4bed2c 100644
--- a/relay/channel/aws/adaptor.go
+++ b/relay/channel/aws/adaptor.go
@@ -123,10 +123,14 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
// 原有的Claude模型处理逻辑
- claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
if err != nil {
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
return claudeReq, err
}
diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go
index 6daf5b6f245e..b8e4a0366dd7 100644
--- a/relay/channel/claude/adaptor.go
+++ b/relay/channel/claude/adaptor.go
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
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/types"
@@ -95,7 +96,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == 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) {
diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go
index 0ba31b1b9baa..8488cc9bb724 100644
--- a/relay/channel/claude/relay-claude.go
+++ b/relay/channel/claude/relay-claude.go
@@ -1,8 +1,6 @@
package claude
import (
- "encoding/json"
- "fmt"
"io"
"net/http"
"strings"
@@ -11,28 +9,18 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
- "github.com/QuantumNous/new-api/relay/channel/openrouter"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"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/relayconvert"
"github.com/QuantumNous/new-api/setting/model_setting"
- "github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types"
"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 {
- return reasonmap.ClaudeStopReasonToOpenAIFinishReason(reason)
+ return relayconvert.StopReasonClaudeToOpenAI(reason)
}
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 {
- 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
- }
- //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
+ return relayconvert.StreamResponseClaude2OpenAI(claudeResponse)
}
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, _ := 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
+ return relayconvert.ResponseClaude2OpenAI(claudeResponse)
}
-type ClaudeResponseInfo struct {
- ResponseId string
- Created int64
- Model string
- ResponseText strings.Builder
- Usage *dto.Usage
- Done bool
-}
+type ClaudeResponseInfo = relayconvert.ClaudeResponseInfo
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
+ openAIUsage := relayconvert.UsageFromClaudeUsage(usage)
+ if openAIUsage == nil {
+ return 0
}
- return splitCacheCreationTokens
+ return openAIUsage.PromptTokens - usage.PromptTokens - usage.PromptTokensDetails.CachedTokens
}
func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
- if usage == nil {
+ mapped := relayconvert.UsageFromClaudeUsage(usage)
+ if mapped == nil {
return dto.Usage{}
}
- clone := *usage
- 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
+ return *mapped
}
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 = 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
+ return relayconvert.BuildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
}
func shouldSkipClaudeMessageDeltaUsagePatch(info *relaycommon.RelayInfo) bool {
@@ -680,109 +76,11 @@ func shouldSkipClaudeMessageDeltaUsagePatch(info *relaycommon.RelayInfo) bool {
}
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
+ return relayconvert.PatchClaudeMessageDeltaUsageData(data, usage)
}
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
- }
-
- // 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
+ return relayconvert.FormatClaudeResponseInfo(claudeResponse, oaiResponse, claudeInfo)
}
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 {
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 {
//
@@ -911,6 +212,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
+ claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
@@ -921,7 +223,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
case types.RelayFormatOpenAI:
openaiResponse := ResponseClaude2OpenAI(&claudeResponse)
openaiResponse.Usage = buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
- responseData, err = json.Marshal(openaiResponse)
+ responseData, err = common.Marshal(openaiResponse)
if err != nil {
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
}
-
-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
-}
diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go
index d6698f7391cc..ca8191e7b9c0 100644
--- a/relay/channel/claude/relay_claude_test.go
+++ b/relay/channel/claude/relay_claude_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"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/require"
)
@@ -41,7 +41,7 @@ func TestResponseOpenAI2ClaudeToolUseInputIsObject(t *testing.T) {
},
},
})
- resp := service.ResponseOpenAI2Claude(&dto.OpenAITextResponse{
+ resp := relayconvert.ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
@@ -322,7 +322,7 @@ func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
-func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
+func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-high",
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.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
@@ -348,7 +348,7 @@ func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *tes
require.Nil(t, claudeRequest.TopK)
}
-func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
+func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-thinking",
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.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go
index 276759b12444..e0ab48e2dbc0 100644
--- a/relay/channel/gemini/adaptor.go
+++ b/relay/channel/gemini/adaptor.go
@@ -9,7 +9,6 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
- "github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
"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) {
- adaptor := openai.Adaptor{}
- oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req)
+ result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, req)
if err != nil {
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) {
@@ -181,13 +183,11 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
-
- geminiRequest, err := CovertOpenAI2Gemini(c, *request, info)
+ result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, request)
if err != nil {
return nil, err
}
-
- return geminiRequest, nil
+ return result.Value, nil
}
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) {
- request, err := preprocessGeminiOpenAIResponsesRequest(request)
+ result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, &request)
if err != nil {
return nil, err
}
-
- chatRequest, err := relayconvert.ResponsesRequestToChatCompletionsRequest(&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 a.ConvertOpenAIRequest(c, info, chatRequest)
+ return geminiRequest, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go
index 5d91121cb880..f3bd79440c5f 100644
--- a/relay/channel/gemini/relay-gemini-native.go
+++ b/relay/channel/gemini/relay-gemini-native.go
@@ -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))
}
- // 计算使用量(基于 UsageMetadata)
- usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
+ // 计算使用量(优先上游 UsageMetadata,缺失时本地估算并保留 Gemini 计费语义)
+ usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
service.IOCopyBytesGracefully(c, resp, responseBody)
diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go
index e39826dd64e7..556d7dc27eed 100644
--- a/relay/channel/gemini/relay-gemini.go
+++ b/relay/channel/gemini/relay-gemini.go
@@ -2,15 +2,12 @@ package gemini
import (
"context"
- "encoding/json"
"errors"
"fmt"
"io"
"net/http"
- "strconv"
"strings"
"time"
- "unicode/utf8"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
@@ -20,1304 +17,98 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
- "github.com/QuantumNous/new-api/setting/model_setting"
- "github.com/QuantumNous/new-api/setting/reasoning"
+ "github.com/QuantumNous/new-api/service/relayconvert"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
- "github.com/samber/lo"
)
-// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob
-var geminiSupportedMimeTypes = map[string]bool{
- "application/pdf": true,
- "audio/mpeg": true,
- "audio/mp3": true,
- "audio/wav": true,
- "image/png": true,
- "image/jpeg": true,
- "image/jpg": true, // support old image/jpeg
- "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,
-}
-
-const thoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
-
-// Gemini 允许的思考预算范围
-const (
- pro25MinBudget = 128
- pro25MaxBudget = 32768
- flash25MaxBudget = 24576
- flash25LiteMinBudget = 512
- flash25LiteMaxBudget = 24576
-)
-
-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")
-}
-
-// clampThinkingBudget 根据模型名称将预算限制在允许的范围内
-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
-}
-
-// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens)
-// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens)
-// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens)
-// "effort": "minimal" - Allocates a minimal portion of tokens (approximately 5% of max_tokens)
-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)
-}
-
-func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
- if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
- modelName := info.UpstreamModelName
- 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 {
- // 如果有reasoningEffort参数,则根据其值设置思考预算
- 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(info.UpstreamModelName); ok && level != "" {
- geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
- IncludeThoughts: true,
- ThinkingLevel: level,
- }
- info.ReasoningEffort = level
- }
- }
-}
-
-// Setting safety to the lowest possible values since Gemini is already powerless enough
-func CovertOpenAI2Gemini(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 {
- geminiSeed := int64(lo.FromPtr(textRequest.Seed))
- geminiRequest.GenerationConfig.Seed = common.GetPointer(geminiSeed)
- }
-
- attachThoughtSignature := (info.ChannelType == constant.ChannelTypeGemini ||
- info.ChannelType == constant.ChannelTypeVertexAi) &&
- model_setting.GetGeminiSettings().FunctionCallThoughtSignatureEnabled
-
- if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) {
- geminiRequest.GenerationConfig.ResponseModalities = []string{
- "TEXT",
- "IMAGE",
- }
- }
- if stopSequences := parseStopSequences(textRequest.Stop); len(stopSequences) > 0 {
- // Gemini supports up to 5 stop sequences
- if len(stopSequences) > 5 {
- stopSequences = stopSequences[:5]
- }
- geminiRequest.GenerationConfig.StopSequences = stopSequences
- }
-
- adaptorWithExtraBody := false
-
- // patch extra_body
- 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)
- }
-
- // eg. {"google":{"thinking_config":{"thinking_budget":5324,"include_thoughts":true}}}
- if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
- if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
- adaptorWithExtraBody = true
- // check error param name like thinkingConfig, should be thinking_config
- 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 {
- // check error param name like thinkingBudget, should be thinking_budget
- 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)
- if budgetInt > 0 {
- // 有正数预算
- tempThinkingConfig.IncludeThoughts = true
- } else {
- // 存在但为0或负数,禁用思考
- tempThinkingConfig.IncludeThoughts = false
- }
- 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 {
- // 避免 panic: 仅在获得配置时分配,防止后续赋值时空指针
- 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
- }
- }
- }
- }
- }
-
- // check error param name like imageConfig, should be image_config
- 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 {
- // check error param name like aspectRatio, should be aspect_ratio
- 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")
- }
- // check error param name like imageSize, should be image_size
- 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")
- }
-
- // convert snake_case to camelCase for Gemini API
- 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 {
- ThinkingAdaptor(&geminiRequest, info, textRequest)
- }
-
- safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList))
- for _, category := range SafetySettingList {
- safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
- Category: category,
- Threshold: model_setting.GetGeminiSafetySetting(category),
- })
- }
- geminiRequest.SafetySettings = safetySettings
-
- // openaiContent.FuncToToolCalls()
- 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 {
-
- params, ok := tool.Function.Parameters.(map[string]interface{})
- if ok {
- if props, hasProps := params["properties"].(map[string]interface{}); hasProps {
- if len(props) == 0 {
- tool.Function.Parameters = nil
- }
- }
- }
- }
- // Clean the parameters before appending
- cleanedParams := cleanFunctionParameters(tool.Function.Parameters)
- tool.Function.Parameters = cleanedParams
- 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)
-
- // [NEW] Convert OpenAI tool_choice to Gemini toolConfig.functionCallingConfig
- // Mapping: "auto" -> "AUTO", "none" -> "NONE", "required" -> "ANY"
- // Object format: {"type": "function", "function": {"name": "xxx"}} -> "ANY" + allowedFunctionNames
- if textRequest.ToolChoice != nil {
- geminiRequest.ToolConfig = convertToolChoiceToGeminiConfig(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 {
- // 先将json.RawMessage解析
- var jsonSchema dto.FormatJsonSchema
- if err := common.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
- cleanedSchema := removeAdditionalPropertiesWithDepth(jsonSchema.Schema, 0)
- geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
- }
- }
- }
- tool_call_ids := make(map[string]string)
- var system_content []string
- //shouldAddDummyModelMessage := false
- for _, message := range textRequest.Messages {
- if message.Role == "system" || message.Role == "developer" {
- system_content = append(system_content, message.StringContent())
- continue
- } else 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",
- })
- }
- var parts = &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts
- name := ""
- if message.Name != nil {
- name = *message.Name
- } else if val, exists := tool_call_ids[message.ToolCallId]; exists {
- name = val
- }
- var contentMap map[string]interface{}
- contentStr := message.StringContent()
-
- // 1. 尝试解析为 JSON 对象
- if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil {
- // 2. 如果失败,尝试解析为 JSON 数组
- var contentSlice []interface{}
- if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil {
- // 如果是数组,包装成对象
- contentMap = map[string]interface{}{"result": contentSlice}
- } else {
- // 3. 如果再次失败,作为纯文本处理
- 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 := attachThoughtSignature && (message.Role == "assistant" || message.Role == "model")
- signatureAttached := false
- // isToolCall := false
- if message.ToolCalls != nil {
- // message.Role = "model"
- // isToolCall = true
- for _, call := range message.ParseToolCalls() {
- args := map[string]interface{}{}
- if call.Function.Arguments != "" {
- if json.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 && hasFunctionCallContent(toolCall.FunctionCall) && len(toolCall.ThoughtSignature) == 0 {
- toolCall.ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue))
- signatureAttached = true
- }
- parts = append(parts, toolCall)
- tool_call_ids[call.ID] = call.Function.Name
- }
- }
-
- openaiContent := message.ParseContent()
- for _, part := range openaiContent {
- if part.Type == dto.ContentTypeText {
- if part.Text == "" {
- continue
- }
- // check markdown image 
- // 使用字符串查找而非正则,避免大文本性能问题
- text := part.Text
- hasMarkdownImage := false
- for {
- // 快速检查是否包含 markdown 图片标记
- startIdx := strings.Index(text, "
- 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,
- })
- }
- }
- // 提取 data URL (从 "](" 后面开始,到 ")" 之前)
- dataUrl := text[bracketIdx+2 : closeIdx]
- format, base64String, err := service.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 {
- imgPart.ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue))
- }
- parts = append(parts, imgPart)
- // 继续处理剩余文本
- text = text[closeIdx+1:]
- }
- // 添加剩余文本或原始文本(如果没有找到 markdown 图片)
- if !hasMarkdownImage {
- parts = append(parts, dto.GeminiPart{
- Text: part.Text,
- })
- }
- } else {
- source := part.ToFileSource()
- if source == nil {
- continue
- }
- base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
- if err != nil {
- return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
- }
-
- // 校验 MimeType 是否在 Gemini 支持的白名单中
- if _, ok := geminiSupportedMimeTypes[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(), getSupportedMimeTypesList())
- }
-
- parts = append(parts, dto.GeminiPart{
- InlineData: &dto.GeminiInlineData{
- MimeType: mimeType,
- Data: base64Data,
- },
- })
- }
- }
-
- // 如果需要附加签名但还没有附加(没有 tool_calls 或 tool_calls 为空),
- // 则在第一个文本 part 上附加 thoughtSignature
- if shouldAttachThoughtSignature && !signatureAttached && len(parts) > 0 {
- for i := range parts {
- if parts[i].Text != "" {
- parts[i].ThoughtSignature = json.RawMessage(strconv.Quote(thoughtSignatureBypassValue))
- break
- }
- }
- }
-
- content.Parts = parts
-
- // there's no assistant role in gemini and API shall vomit if Role is not user or model
- if content.Role == "assistant" {
- content.Role = "model"
- }
- if len(content.Parts) > 0 {
- geminiRequest.Contents = append(geminiRequest.Contents, content)
- }
- }
-
- if len(system_content) > 0 {
- geminiRequest.SystemInstructions = &dto.GeminiChatContent{
- Parts: []dto.GeminiPart{
- {
- Text: strings.Join(system_content, "\n"),
- },
- },
- }
- }
-
- return &geminiRequest, nil
-}
-
-// parseStopSequences 解析停止序列,支持字符串或字符串数组
-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 buildUsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) dto.Usage {
+ usage := relayconvert.UsageFromGeminiMetadata(metadata, fallbackPromptTokens)
+ if usage == nil {
+ return dto.Usage{}
}
+ return *usage
}
-// Helper function to get a list of supported MIME types for error messages
-func getSupportedMimeTypesList() []string {
- keys := make([]string, 0, len(geminiSupportedMimeTypes))
- for k := range geminiSupportedMimeTypes {
- keys = append(keys, k)
- }
- return keys
-}
-
-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
-
-// cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters.
-func cleanFunctionParameters(params interface{}) interface{} {
- return cleanFunctionParametersWithDepth(params, 0)
-}
-
-func cleanFunctionParametersWithDepth(params interface{}, depth int) interface{} {
- if params == nil {
- return nil
- }
-
- if depth >= geminiFunctionSchemaMaxDepth {
- return cleanFunctionParametersShallow(params)
- }
-
- switch v := params.(type) {
- case map[string]interface{}:
- // Keep only Gemini-supported OpenAPI schema subset fields (per official SDK Schema).
- cleanedMap := make(map[string]interface{}, len(v))
- for k, val := range v {
- if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok {
- cleanedMap[k] = val
- }
- }
-
- normalizeGeminiSchemaTypeAndNullable(cleanedMap)
-
- // Clean properties
- if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil {
- cleanedProps := make(map[string]interface{})
- for propName, propValue := range props {
- cleanedProps[propName] = cleanFunctionParametersWithDepth(propValue, depth+1)
- }
- cleanedMap["properties"] = cleanedProps
- }
-
- // Recursively clean items in arrays
- if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil {
- cleanedMap["items"] = cleanFunctionParametersWithDepth(items, depth+1)
- }
- // OpenAPI tuple-style items is not supported by Gemini SDK Schema; keep first to avoid API rejection.
- if itemsArray, ok := cleanedMap["items"].([]interface{}); ok && len(itemsArray) > 0 {
- cleanedMap["items"] = cleanFunctionParametersWithDepth(itemsArray[0], depth+1)
- }
-
- // Recursively clean anyOf
- if nested, ok := cleanedMap["anyOf"].([]interface{}); ok && nested != nil {
- cleanedNested := make([]interface{}, len(nested))
- for i, item := range nested {
- cleanedNested[i] = cleanFunctionParametersWithDepth(item, depth+1)
- }
- cleanedMap["anyOf"] = cleanedNested
- }
-
- return cleanedMap
-
- case []interface{}:
- // Handle arrays of schemas
- cleanedArray := make([]interface{}, len(v))
- for i, item := range v {
- cleanedArray[i] = cleanFunctionParametersWithDepth(item, depth+1)
- }
- return cleanedArray
-
- default:
- // Not a map or array, return as is (e.g., could be a primitive)
- return params
- }
-}
-
-func cleanFunctionParametersShallow(params interface{}) interface{} {
- switch v := params.(type) {
- case map[string]interface{}:
- cleanedMap := make(map[string]interface{}, len(v))
- for k, val := range v {
- if _, ok := geminiOpenAPISchemaAllowedFields[k]; ok {
- cleanedMap[k] = val
- }
- }
- normalizeGeminiSchemaTypeAndNullable(cleanedMap)
- // Stop recursion and avoid retaining huge nested structures.
- delete(cleanedMap, "properties")
- delete(cleanedMap, "items")
- delete(cleanedMap, "anyOf")
- return cleanedMap
- case []interface{}:
- // Prefer an empty list over deep recursion on attacker-controlled inputs.
- return []interface{}{}
- default:
- return params
+func attachEstimatedGeminiBillingUsage(usage *dto.Usage) *dto.Usage {
+ if usage != nil && usage.BillingUsage == nil {
+ usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
+ return usage
}
-func normalizeGeminiSchemaTypeAndNullable(schema map[string]interface{}) {
- rawType, ok := schema["type"]
- if !ok || rawType == nil {
+// patchGeminiZeroCompletionUsage estimates completion tokens locally when upstream
+// usageMetadata was billable but reported zero completion tokens even though output
+// content was actually received. Typical case: the client aborts a stream before the
+// final chunk that carries candidatesTokenCount, leaving prompt-only metadata; without
+// this patch the output side would settle at zero quota.
+func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo, usage *dto.Usage, responseText string, imageCount int) {
+ if usage == nil || usage.CompletionTokens > 0 {
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
- }
+ if responseText == "" && imageCount == 0 {
+ return
}
-
- switch t := rawType.(type) {
- case string:
- normalized, isNull := normalize(t)
- if isNull {
- schema["nullable"] = true
- delete(schema, "type")
- return
- }
- schema["type"] = normalized
- case []interface{}:
- nullable := false
- var chosen string
- for _, item := range t {
- if s, ok := item.(string); ok {
- normalized, isNull := normalize(s)
- if isNull {
- nullable = true
- continue
- }
- if chosen == "" {
- chosen = normalized
- }
- }
- }
- if nullable {
- schema["nullable"] = true
- }
- if chosen != "" {
- schema["type"] = chosen
- } else {
- delete(schema, "type")
- }
+ estimated := service.ResponseText2Usage(c, responseText, info.UpstreamModelName, usage.PromptTokens)
+ usage.CompletionTokens = estimated.CompletionTokens
+ if imageCount != 0 && usage.CompletionTokens == 0 {
+ usage.CompletionTokens = imageCount * 1400
}
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ // Overwrite the metadata-derived billing usage: effectiveBillingUsage prefers
+ // BillingUsage during settlement, so keeping the prompt-only metadata there
+ // would still bill zero completion tokens.
+ usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
-func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} {
- if depth >= 5 {
- return schema
- }
-
- v, ok := schema.(map[string]interface{})
- if !ok || len(v) == 0 {
- return schema
- }
- // 删除所有的title字段
- delete(v, "title")
- delete(v, "$schema")
- // 如果type不为object和array,则直接返回
- if typeVal, exists := v["type"]; !exists || (typeVal != "object" && typeVal != "array") {
- return schema
- }
- switch v["type"] {
- case "object":
- delete(v, "additionalProperties")
- // 处理 properties
- if properties, ok := v["properties"].(map[string]interface{}); ok {
- for key, value := range properties {
- properties[key] = removeAdditionalPropertiesWithDepth(value, depth+1)
- }
- }
- for _, field := range []string{"allOf", "anyOf", "oneOf"} {
- if nested, ok := v[field].([]interface{}); ok {
- for i, item := range nested {
- nested[i] = removeAdditionalPropertiesWithDepth(item, depth+1)
- }
- }
- }
- case "array":
- if items, ok := v["items"].(map[string]interface{}); ok {
- v["items"] = removeAdditionalPropertiesWithDepth(items, depth+1)
- }
+func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
+ if response == nil {
+ return ""
}
-
- return v
-}
-
-func unescapeString(s string) (string, error) {
- var result []rune
- escaped := false
- i := 0
-
- for i < len(s) {
- r, size := utf8.DecodeRuneInString(s[i:]) // 正确解码UTF-8字符
- if r == utf8.RuneError {
- return "", fmt.Errorf("invalid UTF-8 encoding")
- }
-
- if escaped {
- // 如果是转义符后的字符,检查其类型
- switch r {
- case '"':
- result = append(result, '"')
- case '\\':
- result = append(result, '\\')
- case '/':
- result = append(result, '/')
- case 'b':
- result = append(result, '\b')
- case 'f':
- result = append(result, '\f')
- case 'n':
- result = append(result, '\n')
- case 'r':
- result = append(result, '\r')
- case 't':
- result = append(result, '\t')
- case '\'':
- result = append(result, '\'')
- default:
- // 如果遇到一个非法的转义字符,直接按原样输出
- result = append(result, '\\', r)
- }
- escaped = false
- } else {
- if r == '\\' {
- escaped = true // 记录反斜杠作为转义符
- } else {
- result = append(result, r)
+ var text strings.Builder
+ for _, candidate := range response.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ text.WriteString(part.Text)
}
}
- i += size // 移动到下一个字符
}
-
- return string(result), nil
+ return text.String()
}
-func unescapeMapOrSlice(data interface{}) interface{} {
- switch v := data.(type) {
- case map[string]interface{}:
- for k, val := range v {
- v[k] = unescapeMapOrSlice(val)
- }
- case []interface{}:
- for i, val := range v {
- v[i] = unescapeMapOrSlice(val)
- }
- case string:
- if unescaped, err := unescapeString(v); err != nil {
- return v
- } else {
- return unescaped
- }
- }
- return data
-}
-
-func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
- var argsBytes []byte
- var err error
- // 移除 unescapeMapOrSlice 调用,直接使用 json.Marshal
- // JSON 序列化/反序列化已经正确处理了转义字符
- argsBytes, err = json.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,
- },
+func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
+ metadata := response.GetUsageMetadata()
+ if dto.HasGeminiUsageMetadataTokens(metadata) {
+ usage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
+ patchGeminiZeroCompletionUsage(c, info, &usage, geminiResponseUsageText(response), geminiResponseInlineImageCount(response))
+ return usage
}
+ usage := service.ResponseText2Usage(c, geminiResponseUsageText(response), info.UpstreamModelName, info.GetEstimatePromptTokens())
+ attachEstimatedGeminiBillingUsage(usage)
+ return *usage
}
-func buildUsageFromGeminiMetadata(metadata dto.GeminiUsageMetadata, fallbackPromptTokens int) dto.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,
- }
- 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 == "TEXT" {
- usage.PromptTokensDetails.TextTokens += detail.TokenCount
- }
+func geminiResponseInlineImageCount(response *dto.GeminiChatResponse) int {
+ if response == nil {
+ return 0
}
- for _, detail := range metadata.ToolUsePromptTokensDetails {
- if detail.Modality == "AUDIO" {
- usage.PromptTokensDetails.AudioTokens += 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
+ count := 0
+ for _, candidate := range response.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil && part.InlineData.MimeType != "" {
+ count++
+ }
}
}
-
- 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
+ return count
}
func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
- fullTextResponse := dto.OpenAITextResponse{
- Id: helper.GetResponseID(c),
- Object: "chat.completion",
- Created: common.GetTimestamp(),
- 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 {
- // 使用 strings.Builder 直接累积最终 content,避免:
- // 1) 每张 inline image 生成一次中间 "" 字符串
- // 2) 末尾 strings.Join 再分配一份等大缓冲
- // Gemini 图片返回时 InlineData.Data 可能是数 MB 的 base64,
- // 上述两份临时分配在高并发下会显著放大堆驻留。
- var content strings.Builder
- var inlineGrow int
- for _, part := range candidate.Content.Parts {
- if part.InlineData != nil {
- inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
- }
- }
- if inlineGrow > 0 {
- content.Grow(inlineGrow)
- }
- appended := 0
- writeSep := func() {
- if appended > 0 {
- content.WriteByte('\n')
- }
- appended++
- }
- var toolCalls []dto.ToolCallResponse
- for _, part := range candidate.Content.Parts {
- if part.InlineData != nil {
- // 媒体内容
- if strings.HasPrefix(part.InlineData.MimeType, "image") {
- writeSep()
- content.WriteString("
- content.WriteString(part.InlineData.MimeType)
- content.WriteString(";base64,")
- content.WriteString(part.InlineData.Data)
- content.WriteByte(')')
- } else {
- // 其他媒体类型,直接显示链接
- writeSep()
- content.WriteString("[media](data:")
- content.WriteString(part.InlineData.MimeType)
- content.WriteString(";base64,")
- content.WriteString(part.InlineData.Data)
- content.WriteByte(')')
- }
- } else if part.FunctionCall != nil {
- choice.FinishReason = constant.FinishReasonToolCalls
- if call := getResponseToolCall(&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":
- // Safety filter triggered
- choice.FinishReason = constant.FinishReasonContentFilter
- case "RECITATION":
- // Recitation (citation) detected
- choice.FinishReason = constant.FinishReasonContentFilter
- case "BLOCKLIST":
- // Blocklist triggered
- choice.FinishReason = constant.FinishReasonContentFilter
- case "PROHIBITED_CONTENT":
- // Prohibited content detected
- choice.FinishReason = constant.FinishReasonContentFilter
- case "SPII":
- // Sensitive personally identifiable information
- choice.FinishReason = constant.FinishReasonContentFilter
- case "OTHER":
- // Other reasons
- choice.FinishReason = constant.FinishReasonContentFilter
- default:
- choice.FinishReason = constant.FinishReasonContentFilter
- }
- }
- if isToolCall {
- choice.FinishReason = constant.FinishReasonToolCalls
- }
-
- fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
- }
- return &fullTextResponse
+ return relayconvert.ResponseGeminiChat2OpenAI(helper.GetResponseID(c), common.GetTimestamp(), response)
}
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{
- //Role: "assistant",
- },
- }
- // 使用 strings.Builder 直接累积 delta content,避免每张 image / 每个
- // 文本片段都先 `+` 拼出一份临时 string,再 strings.Join 再拷贝一遍。
- 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 {
- // Map Gemini FinishReason to OpenAI finish_reason
- switch *candidate.FinishReason {
- case "STOP":
- // Normal completion
- choice.FinishReason = &constant.FinishReasonStop
- case "MAX_TOKENS":
- // Reached maximum token limit
- choice.FinishReason = &constant.FinishReasonLength
- case "SAFETY":
- // Safety filter triggered
- choice.FinishReason = &constant.FinishReasonContentFilter
- case "RECITATION":
- // Recitation (citation) detected
- choice.FinishReason = &constant.FinishReasonContentFilter
- case "BLOCKLIST":
- // Blocklist triggered
- choice.FinishReason = &constant.FinishReasonContentFilter
- case "PROHIBITED_CONTENT":
- // Prohibited content detected
- choice.FinishReason = &constant.FinishReasonContentFilter
- case "SPII":
- // Sensitive personally identifiable information
- choice.FinishReason = &constant.FinishReasonContentFilter
- case "OTHER":
- // Other reasons
- choice.FinishReason = &constant.FinishReasonContentFilter
- default:
- // Unknown reason, treat as content filter
- choice.FinishReason = &constant.FinishReasonContentFilter
- }
- }
- for _, part := range candidate.Content.Parts {
- if part.InlineData != nil {
- if strings.HasPrefix(part.InlineData.MimeType, "image") {
- writeSep()
- content.WriteString("
- content.WriteString(part.InlineData.MimeType)
- content.WriteString(";base64,")
- content.WriteString(part.InlineData.Data)
- content.WriteByte(')')
- }
- } else if part.FunctionCall != nil {
- isTools = true
- if call := getResponseToolCall(&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)
- }
-
- var response dto.ChatCompletionsStreamResponse
- response.Object = "chat.completion.chunk"
- response.Choices = choices
- return &response, isStop
+ return relayconvert.StreamResponseGeminiChat2OpenAI(geminiResponse)
}
func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
@@ -1344,6 +135,7 @@ func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.Ch
func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response, callback func(data string, geminiResponse *dto.GeminiChatResponse) bool) (*dto.Usage, *types.NewAPIError) {
var usage = &dto.Usage{}
var imageCount int
+ var hasBillableUsageMetadata bool
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
@@ -1370,9 +162,10 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
// 更新使用量统计
- if geminiResponse.UsageMetadata.TotalTokenCount != 0 {
- mappedUsage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
+ if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
+ mappedUsage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
*usage = mappedUsage
+ hasBillableUsageMetadata = true
}
if !callback(data, &geminiResponse) {
@@ -1380,18 +173,20 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
})
- if imageCount != 0 {
- if usage.CompletionTokens == 0 {
- usage.CompletionTokens = imageCount * 1400
- }
- }
-
- if usage.CompletionTokens <= 0 {
+ if !hasBillableUsageMetadata {
if info.ReceivedResponseCount > 0 {
usage = service.ResponseText2Usage(c, responseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
} else {
usage = &dto.Usage{}
}
+ if imageCount != 0 && usage.CompletionTokens == 0 {
+ usage.CompletionTokens = imageCount * 1400
+ usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
+ common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
+ }
+ attachEstimatedGeminiBillingUsage(usage)
+ } else {
+ patchGeminiZeroCompletionUsage(c, info, usage, responseText.String(), imageCount)
}
return usage, nil
@@ -1514,7 +309,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if len(geminiResponse.Candidates) == 0 {
- usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
+ usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
var newAPIError *types.NewAPIError
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -1550,7 +345,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
}
fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
fullTextResponse.Model = info.UpstreamModelName
- usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
+ usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
fullTextResponse.Usage = usage
@@ -1561,8 +356,11 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
- claudeResp := service.ResponseOpenAI2Claude(fullTextResponse, info)
- claudeRespStr, err := common.Marshal(claudeResp)
+ convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
+ if err != nil {
+ return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ claudeRespStr, err := common.Marshal(convertResult.Value)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
@@ -1652,7 +450,7 @@ func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.
})
}
- jsonResponse, jsonErr := json.Marshal(openAIResponse)
+ jsonResponse, jsonErr := common.Marshal(openAIResponse)
if jsonErr != nil {
return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
}
@@ -1747,62 +545,3 @@ func FetchGeminiModels(baseURL, apiKey, proxyURL string) ([]string, error) {
return allModels, nil
}
-
-// convertToolChoiceToGeminiConfig converts OpenAI tool_choice to Gemini toolConfig
-// OpenAI tool_choice values:
-// - "auto": Let the model decide (default)
-// - "none": Don't call any tools
-// - "required": Must call at least one tool
-// - {"type": "function", "function": {"name": "xxx"}}: Call specific function
-//
-// Gemini functionCallingConfig.mode values:
-// - "AUTO": Model decides whether to call functions
-// - "NONE": Model won't call functions
-// - "ANY": Model must call at least one function
-func convertToolChoiceToGeminiConfig(toolChoice any) *dto.ToolConfig {
- if toolChoice == nil {
- return nil
- }
-
- // Handle string values: "auto", "none", "required"
- 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:
- // Unknown string value, default to AUTO
- config.FunctionCallingConfig.Mode = "AUTO"
- }
- return config
- }
-
- // Handle object value: {"type": "function", "function": {"name": "xxx"}}
- if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok {
- if toolChoiceMap["type"] == "function" {
- config := &dto.ToolConfig{
- FunctionCallingConfig: &dto.FunctionCallingConfig{
- Mode: "ANY",
- },
- }
- // Extract function name if specified
- if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok {
- if name, ok := function["name"].(string); ok && name != "" {
- config.FunctionCallingConfig.AllowedFunctionNames = []string{name}
- }
- }
- return config
- }
- // Unsupported map structure (type is not "function"), return nil
- return nil
- }
-
- // Unsupported type, return nil
- return nil
-}
diff --git a/relay/channel/gemini/relay_gemini_usage_test.go b/relay/channel/gemini/relay_gemini_usage_test.go
index c8f9f834300c..bd4c819d0351 100644
--- a/relay/channel/gemini/relay_gemini_usage_test.go
+++ b/relay/channel/gemini/relay_gemini_usage_test.go
@@ -331,3 +331,186 @@ func TestGeminiTextGenerationHandlerUsesEstimatedPromptTokensWhenUsagePromptMiss
require.Equal(t, 100, usage.CompletionTokens)
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))
+}
diff --git a/relay/channel/gemini/relay_responses.go b/relay/channel/gemini/relay_responses.go
index 4ae75a4e0a76..08f96ab400e8 100644
--- a/relay/channel/gemini/relay_responses.go
+++ b/relay/channel/gemini/relay_responses.go
@@ -32,7 +32,7 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if len(geminiResponse.Candidates) == 0 {
- usage := buildUsageFromGeminiMetadata(geminiResponse.UsageMetadata, info.GetEstimatePromptTokens())
+ usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
return &usage, types.NewOpenAIError(
@@ -51,13 +51,21 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
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
- responsesResp, responsesUsage, err := service.ChatCompletionsResponseToResponsesResponse(chatResp, helper.GetResponseID(c))
+ convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, chatResp)
if err != nil {
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 {
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) {
responseID := helper.GetResponseID(c)
created := common.GetTimestamp()
- state := relayconvert.NewChatToResponsesStreamState(responseID, info.UpstreamModelName)
- state.Created = created
+ state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
+ ID: responseID,
+ Model: info.UpstreamModelName,
+ Created: created,
+ })
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ }
finishReason := constant.FinishReasonStop
toolCallIndexByChoice := make(map[int]map[string]int)
nextToolCallIndexByChoice := make(map[int]int)
@@ -90,12 +104,17 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return true
}
sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
- events, err := relayconvert.ChatCompletionsStreamChunkToResponsesEvents(chunk, state)
+ results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, chunk)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
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) {
return false
}
@@ -103,7 +122,7 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
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.Id = responseID
response.Created = created
@@ -143,17 +162,25 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
}
return true
})
- if err != nil {
- return usage, err
+ if streamAPIError != nil {
+ return usage, streamAPIError
}
if streamErr != nil {
return nil, streamErr
}
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) {
return nil, streamErr
}
diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go
index 2c230107de37..e118252352df 100644
--- a/relay/channel/openai/adaptor.go
+++ b/relay/channel/openai/adaptor.go
@@ -42,11 +42,14 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
- // 使用 service.GeminiToOpenAIRequest 转换请求格式
- openaiRequest, err := service.GeminiToOpenAIRequest(request, info)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
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 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))
// }
//}
- aiRequest, err := service.ClaudeToOpenAIRequest(*request, info)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if err != nil {
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 {
// println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest)))
// // Save request body to file for debugging
diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go
index 1bc04f67a348..18758e728d41 100644
--- a/relay/channel/openai/chat_via_responses.go
+++ b/relay/channel/openai/chat_via_responses.go
@@ -41,11 +41,18 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
- chatId := helper.GetResponseID(c)
- chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
+ chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, &responsesResp)
if err != nil {
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 {
text := service.ExtractOutputTextFromResponses(&responsesResp)
@@ -53,17 +60,15 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
chatResp.Usage = *usage
}
- var responseBody []byte
- switch info.RelayFormat {
- case types.RelayFormatClaude:
- claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
- responseBody, err = common.Marshal(claudeResp)
- case types.RelayFormatGemini:
- geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
- responseBody, err = common.Marshal(geminiResp)
- default:
- responseBody, err = common.Marshal(chatResp)
+ responseValue := any(chatResp)
+ if info.RelayFormat != types.RelayFormatOpenAI {
+ targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ }
+ responseValue = targetResult.Value
}
+ responseBody, err := common.Marshal(responseValue)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
}
@@ -145,28 +150,33 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
}
accumulator.SupplementResponseOutput(finalResponse)
- chatId := helper.GetResponseID(c)
- chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(finalResponse, chatId)
+ chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, finalResponse)
if err != nil {
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 {
text := service.ExtractOutputTextFromResponses(finalResponse)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
chatResp.Usage = *usage
}
- var responseBody []byte
- switch info.RelayFormat {
- case types.RelayFormatClaude:
- claudeResp := service.ResponseOpenAI2Claude(chatResp, info)
- responseBody, err = common.Marshal(claudeResp)
- case types.RelayFormatGemini:
- geminiResp := service.ResponseOpenAI2Gemini(chatResp, info)
- responseBody, err = common.Marshal(geminiResp)
- default:
- responseBody, err = common.Marshal(chatResp)
+ responseValue := any(chatResp)
+ if info.RelayFormat != types.RelayFormatOpenAI {
+ targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+ }
+ responseValue = targetResult.Value
}
+ responseBody, err := common.Marshal(responseValue)
if err != nil {
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)
createAt := time.Now().Unix()
- state := relayconvert.NewResponsesToChatStreamState(info.UpstreamModelName, false)
- state.ID = responseId
- state.Created = createAt
+ state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAIResponses, info.RelayFormat, relayconvert.ResponseStreamOptions{
+ ID: responseId,
+ Model: info.UpstreamModelName,
+ Created: createAt,
+ })
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ }
streamErr := (*types.NewAPIError)(nil)
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo == nil {
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
}
- sendChatChunk := func(chunk dto.ChatCompletionsStreamResponse) bool {
- if len(chunk.Choices) == 0 && chunk.Usage == nil {
+ sendGeminiResponse := func(geminiResponse *dto.GeminiChatResponse) bool {
+ if geminiResponse == nil {
return true
}
- if info.RelayFormat == types.RelayFormatOpenAI {
- 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)
+ geminiResponseStr, err := common.Marshal(geminiResponse)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
- if err := HandleStreamFormat(c, info, string(chunkData), false, false); err != nil {
- streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
+ _ = 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 true
}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
@@ -243,14 +293,14 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return
}
- chunks, err := relayconvert.ResponsesStreamEventToChatChunks(&streamResp, state)
+ results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &streamResp)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
- for _, chunk := range chunks {
- if !sendChatChunk(chunk) {
+ for _, result := range results {
+ if !sendStreamResult(result) {
sr.Stop(streamErr)
return
}
@@ -261,22 +311,26 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, streamErr
}
- usage := state.Usage
- if usage.TotalTokens == 0 {
+ usage := state.Usage()
+ if usage == nil || usage.TotalTokens == 0 {
usage = service.ResponseText2Usage(c, state.UsageText(), info.UpstreamModelName, info.GetEstimatePromptTokens())
- state.Usage = usage
+ state.SetUsage(usage)
}
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
- for _, chunk := range relayconvert.FinalizeResponsesToChatStream(state) {
- if !sendChatChunk(chunk) {
+ finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
+ if err != nil {
+ return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
+ }
+ for _, result := range finalResults {
+ if !sendStreamResult(result) {
return nil, streamErr
}
}
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)
}
}
diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go
index d10176b58fe1..840708eb3fea 100644
--- a/relay/channel/openai/helper.go
+++ b/relay/channel/openai/helper.go
@@ -1,6 +1,7 @@
package openai
import (
+ "fmt"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -10,6 +11,7 @@ import (
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/service/relayconvert"
"github.com/QuantumNous/new-api/types"
"github.com/samber/lo"
@@ -41,7 +43,14 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
if streamResponse.Usage != nil {
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 {
helper.ClaudeData(c, *resp)
}
@@ -55,7 +64,14 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
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,表示没有实际内容,跳过发送
if geminiResponse == nil {
@@ -165,7 +181,16 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
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 {
_ = helper.ClaudeData(c, *resp)
}
@@ -183,7 +208,16 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
// 而包含最后一段文本输出的响应(倒数第二个)的 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 流响应开头的空数据
if geminiResponse == nil {
diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go
index de40fe7071fc..50415c8b3533 100644
--- a/relay/channel/openai/relay-openai.go
+++ b/relay/channel/openai/relay-openai.go
@@ -14,6 +14,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
+ "github.com/QuantumNous/new-api/service/relayconvert"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
@@ -271,15 +272,21 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
break
}
case types.RelayFormatClaude:
- claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info)
- claudeRespStr, err := common.Marshal(claudeResp)
+ convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
+ if err != nil {
+ return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ claudeRespStr, err := common.Marshal(convertResult.Value)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
responseBody = claudeRespStr
case types.RelayFormatGemini:
- geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info)
- geminiRespStr, err := common.Marshal(geminiResp)
+ convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
+ if err != nil {
+ return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+ }
+ geminiRespStr, err := common.Marshal(convertResult.Value)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
diff --git a/relay/channel/openai/responses_via_chat.go b/relay/channel/openai/responses_via_chat.go
index 180c4a97cdaf..549479d765e7 100644
--- a/relay/channel/openai/responses_via_chat.go
+++ b/relay/channel/openai/responses_via_chat.go
@@ -35,11 +35,18 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
- responseID := helper.GetResponseID(c)
- responsesResp, usage, err := service.ChatCompletionsResponseToResponsesResponse(&chatResp, responseID)
+ if responseID := helper.GetResponseID(c); responseID != "" {
+ chatResp.Id = responseID
+ }
+ convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
if err != nil {
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 {
text := service.ExtractOutputTextFromResponses(responsesResp)
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)
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)
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
@@ -97,13 +110,19 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return
}
- events, err := relayconvert.ChatCompletionsStreamChunkToResponsesEvents(&chunk, state)
+ results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &chunk)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
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) {
sr.Stop(streamErr)
return
@@ -115,13 +134,21 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return nil, streamErr
}
- usage := state.Usage
+ usage := state.Usage()
if usage == nil || usage.TotalTokens == 0 {
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) {
return nil, streamErr
}
diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go
index 7f087c21b90c..3145fce2653c 100644
--- a/relay/channel/vertex/adaptor.go
+++ b/relay/channel/vertex/adaptor.go
@@ -1,7 +1,6 @@
package vertex
import (
- "encoding/json"
"errors"
"fmt"
"io"
@@ -16,6 +15,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"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/reasoning"
"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 {
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 {
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)
}
if a.RequestMode == RequestModeClaude {
- claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *request)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
if err != nil {
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)
c.Set("request_model", claudeReq.Model)
info.UpstreamModelName = claudeReq.Model
return vertexClaudeReq, nil
} else if a.RequestMode == RequestModeGemini {
- geminiRequest, err := gemini.CovertOpenAI2Gemini(c, *request, info)
+ result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, 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)
+ }
c.Set("request_model", request.Model)
return geminiRequest, nil
} else if a.RequestMode == RequestModeOpenSource {
diff --git a/relay/chat_completions_via_responses.go b/relay/chat_completions_via_responses.go
index 80b124759153..3d44abbb85fd 100644
--- a/relay/chat_completions_via_responses.go
+++ b/relay/chat_completions_via_responses.go
@@ -1,6 +1,7 @@
package relay
import (
+ "fmt"
"io"
"net/http"
"strings"
@@ -92,11 +93,14 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
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 {
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
savedRequestURLPath := info.RequestURLPath
diff --git a/relay/claude_handler.go b/relay/claude_handler.go
index 527363205a1f..e4a4920306f1 100644
--- a/relay/claude_handler.go
+++ b/relay/claude_handler.go
@@ -135,10 +135,14 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
!info.ChannelSetting.PassThroughBodyEnabled &&
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 {
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)
if newApiErr != nil {
diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go
index eb027587e0c4..ab9937595c5a 100644
--- a/relay/common/relay_utils.go
+++ b/relay/common/relay_utils.go
@@ -3,6 +3,7 @@ package common
import (
"fmt"
"net/http"
+ "net/url"
"strconv"
"strings"
@@ -36,6 +37,67 @@ func GetFullRequestURL(baseURL string, requestURL string, channelType int) strin
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 {
query := c.Request.URL.Query()
apiVersion := query.Get("api-version")
diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go
index 5bd51e8eee6d..0746d34468a3 100644
--- a/relay/common/relay_utils_test.go
+++ b/relay/common/relay_utils_test.go
@@ -3,14 +3,59 @@ package common
import (
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
"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) {
gin.SetMode(gin.TestMode)
body := strings.NewReader(`{"model":"wan2.7-i2v","prompt":"animate","image":" https://example.com/first.png "}`)
diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go
index 8f64552a696d..2130c400e536 100644
--- a/relay/gemini_handler.go
+++ b/relay/gemini_handler.go
@@ -10,10 +10,10 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
- "github.com/QuantumNous/new-api/relay/channel/gemini"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"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/types"
@@ -84,7 +84,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
}
if request.GenerationConfig.ThinkingConfig == nil {
- gemini.ThinkingAdaptor(request, info)
+ relayconvert.ApplyGeminiThinkingConfig(request, info)
}
}
diff --git a/service/billing_usage.go b/service/billing_usage.go
new file mode 100644
index 000000000000..a8d47917b39d
--- /dev/null
+++ b/service/billing_usage.go
@@ -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
+ }
+}
diff --git a/service/convert.go b/service/convert.go
index a0ae8cb4df97..c62b7d7ac813 100644
--- a/service/convert.go
+++ b/service/convert.go
@@ -1,1005 +1,27 @@
package service
import (
- "encoding/json"
- "fmt"
- "strings"
-
- "github.com/QuantumNous/new-api/common"
- "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
- "github.com/QuantumNous/new-api/relay/channel/openrouter"
relaycommon "github.com/QuantumNous/new-api/relay/common"
- "github.com/QuantumNous/new-api/relay/reasonmap"
- "github.com/samber/lo"
+ "github.com/QuantumNous/new-api/service/relayconvert"
)
-func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
- openAIRequest := dto.GeneralOpenAIRequest{
- Model: claudeRequest.Model,
- Temperature: claudeRequest.Temperature,
- }
- if claudeRequest.MaxTokens != nil {
- openAIRequest.MaxTokens = lo.ToPtr(lo.FromPtr(claudeRequest.MaxTokens))
- }
- if claudeRequest.TopP != nil {
- openAIRequest.TopP = lo.ToPtr(lo.FromPtr(claudeRequest.TopP))
- }
- if claudeRequest.TopK != nil {
- openAIRequest.TopK = lo.ToPtr(lo.FromPtr(claudeRequest.TopK))
- }
- if claudeRequest.Stream != nil {
- openAIRequest.Stream = lo.ToPtr(lo.FromPtr(claudeRequest.Stream))
- }
-
- isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter
-
- if isOpenRouter {
- if effort := claudeRequest.GetEfforts(); effort != "" {
- effortBytes, _ := json.Marshal(effort)
- openAIRequest.Verbosity = effortBytes
- }
- if claudeRequest.Thinking != nil {
- var reasoning openrouter.RequestReasoning
- if claudeRequest.Thinking.Type == "enabled" {
- reasoning = openrouter.RequestReasoning{
- Enabled: true,
- MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
- }
- } else if claudeRequest.Thinking.Type == "adaptive" {
- reasoning = openrouter.RequestReasoning{
- Enabled: true,
- }
- }
- reasoningJSON, err := json.Marshal(reasoning)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
- }
- openAIRequest.Reasoning = reasoningJSON
- }
- } else {
- thinkingSuffix := "-thinking"
- if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
- !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
- openAIRequest.Model = openAIRequest.Model + thinkingSuffix
- }
- }
-
- // Convert stop sequences
- if len(claudeRequest.StopSequences) == 1 {
- openAIRequest.Stop = claudeRequest.StopSequences[0]
- } else if len(claudeRequest.StopSequences) > 1 {
- openAIRequest.Stop = claudeRequest.StopSequences
- }
-
- // Convert tools
- 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
-
- // Convert messages
- openAIMessages := make([]dto.Message, 0)
-
- // Add system message if present
- 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(info.UpstreamModelName, "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,
- }
-
- //log.Printf("claudeMessage.Content: %v", claudeMessage.Content)
- if claudeMessage.IsStringContent() {
- openAIMessage.SetStringContent(claudeMessage.GetStringContent())
- } else {
- content, err := claudeMessage.ParseContent()
- if err != nil {
- return nil, err
- }
- contents := content
- var toolCalls []dto.ToolCallRequest
- mediaMessages := make([]dto.MediaContent, 0, len(contents))
-
- for _, mediaMsg := range contents {
- switch mediaMsg.Type {
- case "text", "input_text":
- message := dto.MediaContent{
- Type: "text",
- Text: mediaMsg.GetText(),
- CacheControl: mediaMsg.CacheControl,
- }
- mediaMessages = append(mediaMessages, message)
- case "image":
- // Handle image conversion (base64 to URL or keep as is)
- imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data)
- //textContent += fmt.Sprintf("[Image: %s]", imageData)
- 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: toJSONString(mediaMsg.Input),
- },
- }
- toolCalls = append(toolCalls, toolCall)
- case "tool_result":
- // Add tool result as a separate message
- toolName := mediaMsg.Name
- if toolName == "" {
- toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId)
- }
- oaiToolMessage := dto.Message{
- Role: "tool",
- Name: &toolName,
- ToolCallId: mediaMsg.ToolUseId,
- }
- //oaiToolMessage.SetStringContent(*mediaMsg.GetMediaContent().Text)
- if mediaMsg.IsStringContent() {
- oaiToolMessage.SetStringContent(mediaMsg.GetStringContent())
- } else {
- mediaContents := mediaMsg.ParseMediaContent()
- encodeJson, _ := common.Marshal(mediaContents)
- oaiToolMessage.SetStringContent(string(encodeJson))
- }
- 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 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
- }
- 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,
- }
- 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
+ return relayconvert.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
- 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
+ return relayconvert.StreamResponseOpenAI2Claude(openAIResponse, info)
}
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)
+ return relayconvert.ResponseOpenAI2Claude(openAIResponse, info)
}
-func toJSONString(v interface{}) string {
- b, err := json.Marshal(v)
- if err != nil {
- return "{}"
- }
- return string(b)
-}
-
-func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) {
- openaiRequest := &dto.GeneralOpenAIRequest{
- Model: info.UpstreamModelName,
- Stream: lo.ToPtr(info.IsStream),
- }
-
- // 转换 messages
- var messages []dto.Message
- for _, content := range geminiRequest.Contents {
- message := dto.Message{
- Role: convertGeminiRoleToOpenAI(content.Role),
- }
-
- // 处理 parts
- 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 {
- // 处理 Gemini 的工具调用
- toolCall := dto.ToolCallRequest{
- ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID
- Type: "function",
- Function: dto.FunctionRequest{
- Name: part.FunctionCall.FunctionName,
- Arguments: toJSONString(part.FunctionCall.Arguments),
- },
- }
- toolCalls = append(toolCalls, toolCall)
- } else if part.FunctionResponse != nil {
- // 处理 Gemini 的工具响应,创建单独的 tool 消息
- toolMessage := dto.Message{
- Role: "tool",
- ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID
- }
- toolMessage.SetStringContent(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 = lo.ToPtr(*geminiRequest.GenerationConfig.TopP)
- }
- if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
- openaiRequest.TopK = lo.ToPtr(int(*geminiRequest.GenerationConfig.TopK))
- }
- if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
- openaiRequest.MaxTokens = lo.ToPtr(*geminiRequest.GenerationConfig.MaxOutputTokens)
- }
- // gemini stop sequences 最多 5 个,openai stop 最多 4 个
- if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
- openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4]
- }
- if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
- openaiRequest.N = lo.ToPtr(*geminiRequest.GenerationConfig.CandidateCount)
- }
-
- // 转换工具调用
- if len(geminiRequest.GetTools()) > 0 {
- var tools []dto.ToolCallRequest
- for _, tool := range geminiRequest.GetTools() {
- if tool.FunctionDeclarations != nil {
- 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
- }
- }
-
- // gemini system instructions
- 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 {
- var texts []string
- for _, part := range parts {
- if part.Text != "" {
- texts = append(texts, part.Text)
- }
- }
- return strings.Join(texts, "\n")
-}
-
-// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse {
- geminiResponse := &dto.GeminiChatResponse{
- Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
- UsageMetadata: dto.GeminiUsageMetadata{
- PromptTokenCount: openAIResponse.PromptTokens,
- CandidatesTokenCount: openAIResponse.CompletionTokens,
- TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens,
- },
- }
-
- 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
+ return relayconvert.ResponseOpenAI2Gemini(openAIResponse, info)
}
-// 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
- }
-
- geminiResponse := &dto.GeminiChatResponse{
- Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)),
- UsageMetadata: dto.GeminiUsageMetadata{
- PromptTokenCount: info.GetEstimatePromptTokens(),
- CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息
- TotalTokenCount: info.GetEstimatePromptTokens(),
- },
- }
-
- if openAIResponse.Usage != nil {
- geminiResponse.UsageMetadata.PromptTokenCount = openAIResponse.Usage.PromptTokens
- geminiResponse.UsageMetadata.CandidatesTokenCount = openAIResponse.Usage.CompletionTokens
- geminiResponse.UsageMetadata.TotalTokenCount = openAIResponse.Usage.TotalTokens
- }
-
- 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 := json.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
+ return relayconvert.StreamResponseOpenAI2Gemini(openAIResponse, info)
}
diff --git a/service/convert_test.go b/service/convert_test.go
new file mode 100644
index 000000000000..1d07ee07349b
--- /dev/null
+++ b/service/convert_test.go
@@ -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
+}
diff --git a/service/relayconvert/internal/claude_messages/to_oai_chat_req.go b/service/relayconvert/internal/claude_messages/to_oai_chat_req.go
new file mode 100644
index 000000000000..e7ef305c1898
--- /dev/null
+++ b/service/relayconvert/internal/claude_messages/to_oai_chat_req.go
@@ -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)
+}
diff --git a/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go b/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go
new file mode 100644
index 000000000000..a1d8488e8048
--- /dev/null
+++ b/service/relayconvert/internal/claude_messages/to_oai_chat_resp.go
@@ -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
+}
diff --git a/service/relayconvert/internal/gemini_chat/to_oai_chat_req.go b/service/relayconvert/internal/gemini_chat/to_oai_chat_req.go
new file mode 100644
index 000000000000..2557e801f4af
--- /dev/null
+++ b/service/relayconvert/internal/gemini_chat/to_oai_chat_req.go
@@ -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")
+}
diff --git a/service/relayconvert/internal/gemini_chat/to_oai_chat_resp.go b/service/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
new file mode 100644
index 000000000000..68181db38ab3
--- /dev/null
+++ b/service/relayconvert/internal/gemini_chat/to_oai_chat_resp.go
@@ -0,0 +1,298 @@
+package geminichat
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+)
+
+func UsageFromGeminiMetadata(metadata *dto.GeminiUsageMetadata, fallbackPromptTokens int) *dto.Usage {
+ if metadata == nil {
+ if fallbackPromptTokens <= 0 {
+ return nil
+ }
+ usage := &dto.Usage{PromptTokens: fallbackPromptTokens}
+ usage.PromptTokensDetails.TextTokens = fallbackPromptTokens
+ return usage
+ }
+
+ promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
+ if promptTokens <= 0 && fallbackPromptTokens > 0 {
+ promptTokens = fallbackPromptTokens
+ }
+
+ usage := &dto.Usage{
+ PromptTokens: promptTokens,
+ CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
+ TotalTokens: metadata.TotalTokenCount,
+ BillingUsage: dto.CloneBillingUsage(metadata.BillingUsage),
+ }
+ if usage.BillingUsage == nil {
+ usage.BillingUsage = dto.NewGeminiChatBillingUsage(metadata)
+ }
+ usage.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
+ usage.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
+
+ for _, detail := range metadata.PromptTokensDetails {
+ if detail.Modality == "AUDIO" {
+ usage.PromptTokensDetails.AudioTokens += detail.TokenCount
+ } else if detail.Modality == "IMAGE" {
+ usage.PromptTokensDetails.ImageTokens += detail.TokenCount
+ } else if detail.Modality == "TEXT" {
+ usage.PromptTokensDetails.TextTokens += detail.TokenCount
+ }
+ }
+ for _, detail := range metadata.ToolUsePromptTokensDetails {
+ if detail.Modality == "AUDIO" {
+ usage.PromptTokensDetails.AudioTokens += detail.TokenCount
+ } else if detail.Modality == "IMAGE" {
+ usage.PromptTokensDetails.ImageTokens += detail.TokenCount
+ } else if detail.Modality == "TEXT" {
+ usage.PromptTokensDetails.TextTokens += detail.TokenCount
+ }
+ }
+ for _, detail := range metadata.CandidatesTokensDetails {
+ switch detail.Modality {
+ case "IMAGE":
+ usage.CompletionTokenDetails.ImageTokens += detail.TokenCount
+ case "AUDIO":
+ usage.CompletionTokenDetails.AudioTokens += detail.TokenCount
+ case "TEXT":
+ usage.CompletionTokenDetails.TextTokens += detail.TokenCount
+ }
+ }
+
+ if usage.TotalTokens > 0 && usage.CompletionTokens <= 0 {
+ usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
+ }
+
+ if usage.PromptTokens > 0 && usage.PromptTokensDetails.TextTokens == 0 && usage.PromptTokensDetails.AudioTokens == 0 {
+ usage.PromptTokensDetails.TextTokens = usage.PromptTokens
+ }
+
+ return usage
+}
+
+func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiChatResponse) *dto.OpenAITextResponse {
+ fullTextResponse := dto.OpenAITextResponse{
+ Id: id,
+ Object: "chat.completion",
+ Created: created,
+ Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)),
+ }
+ isToolCall := false
+ for _, candidate := range response.Candidates {
+ choice := dto.OpenAITextResponseChoice{
+ Index: int(candidate.Index),
+ Message: dto.Message{
+ Role: "assistant",
+ Content: "",
+ },
+ FinishReason: constant.FinishReasonStop,
+ }
+ if len(candidate.Content.Parts) > 0 {
+ var content strings.Builder
+ var inlineGrow int
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil {
+ inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
+ }
+ }
+ if inlineGrow > 0 {
+ content.Grow(inlineGrow)
+ }
+ appended := 0
+ writeSep := func() {
+ if appended > 0 {
+ content.WriteByte('\n')
+ }
+ appended++
+ }
+ var toolCalls []dto.ToolCallResponse
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil {
+ if strings.HasPrefix(part.InlineData.MimeType, "image") {
+ writeSep()
+ content.WriteString("
+ content.WriteString(part.InlineData.MimeType)
+ content.WriteString(";base64,")
+ content.WriteString(part.InlineData.Data)
+ content.WriteByte(')')
+ } else {
+ writeSep()
+ content.WriteString("[media](data:")
+ content.WriteString(part.InlineData.MimeType)
+ content.WriteString(";base64,")
+ content.WriteString(part.InlineData.Data)
+ content.WriteByte(')')
+ }
+ } else if part.FunctionCall != nil {
+ choice.FinishReason = constant.FinishReasonToolCalls
+ if call := geminiResponseToolCall(&part); call != nil {
+ toolCalls = append(toolCalls, *call)
+ }
+ } else if part.Thought {
+ choice.Message.ReasoningContent = &part.Text
+ } else {
+ if part.ExecutableCode != nil {
+ writeSep()
+ content.WriteString("```")
+ content.WriteString(part.ExecutableCode.Language)
+ content.WriteByte('\n')
+ content.WriteString(part.ExecutableCode.Code)
+ content.WriteString("\n```")
+ } else if part.CodeExecutionResult != nil {
+ writeSep()
+ content.WriteString("```output\n")
+ content.WriteString(part.CodeExecutionResult.Output)
+ content.WriteString("\n```")
+ } else if part.Text != "\n" {
+ writeSep()
+ content.WriteString(part.Text)
+ }
+ }
+ }
+ if len(toolCalls) > 0 {
+ choice.Message.SetToolCalls(toolCalls)
+ isToolCall = true
+ }
+ choice.Message.SetStringContent(content.String())
+ }
+ if candidate.FinishReason != nil {
+ switch *candidate.FinishReason {
+ case "STOP":
+ choice.FinishReason = constant.FinishReasonStop
+ case "MAX_TOKENS":
+ choice.FinishReason = constant.FinishReasonLength
+ case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
+ choice.FinishReason = constant.FinishReasonContentFilter
+ default:
+ choice.FinishReason = constant.FinishReasonContentFilter
+ }
+ }
+ if isToolCall {
+ choice.FinishReason = constant.FinishReasonToolCalls
+ }
+
+ fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
+ }
+ return &fullTextResponse
+}
+
+func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) {
+ choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates))
+ isStop := false
+ for _, candidate := range geminiResponse.Candidates {
+ if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" {
+ isStop = true
+ candidate.FinishReason = nil
+ }
+ choice := dto.ChatCompletionsStreamResponseChoice{
+ Index: int(candidate.Index),
+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{},
+ }
+ var content strings.Builder
+ var inlineGrow int
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil {
+ inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
+ }
+ }
+ if inlineGrow > 0 {
+ content.Grow(inlineGrow)
+ }
+ appended := 0
+ writeSep := func() {
+ if appended > 0 {
+ content.WriteByte('\n')
+ }
+ appended++
+ }
+ isTools := false
+ isThought := false
+ if candidate.FinishReason != nil {
+ switch *candidate.FinishReason {
+ case "STOP":
+ choice.FinishReason = &constant.FinishReasonStop
+ case "MAX_TOKENS":
+ choice.FinishReason = &constant.FinishReasonLength
+ case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER":
+ choice.FinishReason = &constant.FinishReasonContentFilter
+ default:
+ choice.FinishReason = &constant.FinishReasonContentFilter
+ }
+ }
+ for _, part := range candidate.Content.Parts {
+ if part.InlineData != nil {
+ if strings.HasPrefix(part.InlineData.MimeType, "image") {
+ writeSep()
+ content.WriteString("
+ content.WriteString(part.InlineData.MimeType)
+ content.WriteString(";base64,")
+ content.WriteString(part.InlineData.Data)
+ content.WriteByte(')')
+ }
+ } else if part.FunctionCall != nil {
+ isTools = true
+ if call := geminiResponseToolCall(&part); call != nil {
+ call.SetIndex(len(choice.Delta.ToolCalls))
+ choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call)
+ }
+ } else if part.Thought {
+ isThought = true
+ writeSep()
+ content.WriteString(part.Text)
+ } else {
+ if part.ExecutableCode != nil {
+ writeSep()
+ content.WriteString("```")
+ content.WriteString(part.ExecutableCode.Language)
+ content.WriteByte('\n')
+ content.WriteString(part.ExecutableCode.Code)
+ content.WriteString("\n```\n")
+ } else if part.CodeExecutionResult != nil {
+ writeSep()
+ content.WriteString("```output\n")
+ content.WriteString(part.CodeExecutionResult.Output)
+ content.WriteString("\n```\n")
+ } else if part.Text != "\n" {
+ writeSep()
+ content.WriteString(part.Text)
+ }
+ }
+ }
+ if isThought {
+ choice.Delta.SetReasoningContent(content.String())
+ } else {
+ choice.Delta.SetContentString(content.String())
+ }
+ if isTools {
+ choice.FinishReason = &constant.FinishReasonToolCalls
+ }
+ choices = append(choices, choice)
+ }
+
+ response := dto.ChatCompletionsStreamResponse{
+ Object: "chat.completion.chunk",
+ Choices: choices,
+ }
+ return &response, isStop
+}
+
+func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
+ argsBytes, err := common.Marshal(item.FunctionCall.Arguments)
+ if err != nil {
+ return nil
+ }
+ return &dto.ToolCallResponse{
+ ID: fmt.Sprintf("call_%s", common.GetUUID()),
+ Type: "function",
+ Function: dto.FunctionResponse{
+ Arguments: string(argsBytes),
+ Name: item.FunctionCall.FunctionName,
+ },
+ }
+}
diff --git a/service/relayconvert/internal/jsonutil/stringify.go b/service/relayconvert/internal/jsonutil/stringify.go
new file mode 100644
index 000000000000..99ce8b898020
--- /dev/null
+++ b/service/relayconvert/internal/jsonutil/stringify.go
@@ -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)
+}
diff --git a/service/relayconvert/regex.go b/service/relayconvert/internal/matcher/regex.go
similarity index 88%
rename from service/relayconvert/regex.go
rename to service/relayconvert/internal/matcher/regex.go
index d65f0aa79190..51f0dd9d97e9 100644
--- a/service/relayconvert/regex.go
+++ b/service/relayconvert/internal/matcher/regex.go
@@ -1,4 +1,4 @@
-package relayconvert
+package matcher
import (
"regexp"
@@ -7,7 +7,7 @@ import (
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 == "" {
return false
}
diff --git a/service/relayconvert/internal/media/media.go b/service/relayconvert/internal/media/media.go
new file mode 100644
index 000000000000..3db158e4eb61
--- /dev/null
+++ b/service/relayconvert/internal/media/media.go
@@ -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)
+}
diff --git a/service/relayconvert/internal/meta/relay_info.go b/service/relayconvert/internal/meta/relay_info.go
new file mode 100644
index 000000000000..926f3c33c7c0
--- /dev/null
+++ b/service/relayconvert/internal/meta/relay_info.go
@@ -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
+}
diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_req.go b/service/relayconvert/internal/oai_chat/to_claude_messages_req.go
new file mode 100644
index 000000000000..346eb906e48a
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_claude_messages_req.go
@@ -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
+}
diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go
new file mode 100644
index 000000000000..67e174f71c8c
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp.go
@@ -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)
+}
diff --git a/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go b/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
new file mode 100644
index 000000000000..0b121d0584a0
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_claude_messages_resp_test.go
@@ -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
+}
diff --git a/service/relayconvert/internal/oai_chat/to_gemini_chat_req.go b/service/relayconvert/internal/oai_chat/to_gemini_chat_req.go
new file mode 100644
index 000000000000..7862218291e4
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_gemini_chat_req.go
@@ -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
+}
diff --git a/service/relayconvert/internal/oai_chat/to_gemini_chat_resp.go b/service/relayconvert/internal/oai_chat/to_gemini_chat_resp.go
new file mode 100644
index 000000000000..fb4df2952336
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_gemini_chat_resp.go
@@ -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)
+}
diff --git a/service/relayconvert/internal/oai_chat/to_gemini_chat_resp_test.go b/service/relayconvert/internal/oai_chat/to_gemini_chat_resp_test.go
new file mode 100644
index 000000000000..669d10a2b930
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_gemini_chat_resp_test.go
@@ -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
+}
diff --git a/service/relayconvert/policy.go b/service/relayconvert/internal/oai_chat/to_oai_responses_policy.go
similarity index 69%
rename from service/relayconvert/policy.go
rename to service/relayconvert/internal/oai_chat/to_oai_responses_policy.go
index ee25e69f765c..350d847c9f70 100644
--- a/service/relayconvert/policy.go
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_policy.go
@@ -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 {
if !policy.IsChannelEnabled(channelID, channelType) {
return false
}
- return matchAnyRegex(policy.ModelPatterns, model)
+ return matcher.MatchAnyRegex(policy.ModelPatterns, model)
}
func ShouldChatCompletionsUseResponsesGlobal(channelID int, channelType int, model string) bool {
diff --git a/service/relayconvert/chat_to_responses.go b/service/relayconvert/internal/oai_chat/to_oai_responses_req.go
similarity index 99%
rename from service/relayconvert/chat_to_responses.go
rename to service/relayconvert/internal/oai_chat/to_oai_responses_req.go
index 3f51e2807bc7..0da9f44902cd 100644
--- a/service/relayconvert/chat_to_responses.go
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_req.go
@@ -1,4 +1,4 @@
-package relayconvert
+package oaichat
import (
"encoding/json"
diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_req_test.go b/service/relayconvert/internal/oai_chat/to_oai_responses_req_test.go
new file mode 100644
index 000000000000..1637603a8db3
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_req_test.go
@@ -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
+}
diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
new file mode 100644
index 000000000000..2d6a5b5273e2
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp.go
@@ -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
+}
diff --git a/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go b/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
new file mode 100644
index 000000000000..a5034a0e80a3
--- /dev/null
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_resp_test.go
@@ -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
+}
diff --git a/service/relayconvert/chat_to_responses_response.go b/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
similarity index 70%
rename from service/relayconvert/chat_to_responses_response.go
rename to service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
index 95416953bb7c..75c602dd9376 100644
--- a/service/relayconvert/chat_to_responses_response.go
+++ b/service/relayconvert/internal/oai_chat/to_oai_responses_stream_resp.go
@@ -1,133 +1,14 @@
-package relayconvert
+package oaichat
import (
- "errors"
"fmt"
"sort"
"strings"
"time"
- "github.com/QuantumNous/new-api/common"
"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 string
Payload dto.ResponsesStreamResponse
@@ -534,72 +415,3 @@ func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool,
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
-}
diff --git a/service/relayconvert/internal/oai_responses/req_helpers.go b/service/relayconvert/internal/oai_responses/req_helpers.go
new file mode 100644
index 000000000000..969fb1ec95d4
--- /dev/null
+++ b/service/relayconvert/internal/oai_responses/req_helpers.go
@@ -0,0 +1,263 @@
+package oairesponses
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/types"
+)
+
+func openAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
+ responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
+ if !ok {
+ if value, ok := request.(dto.OpenAIResponsesRequest); ok {
+ responsesRequest = &value
+ }
+ }
+ if responsesRequest == nil {
+ return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
+ }
+ return responsesRequest, nil
+}
+
+func OpenAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
+ return openAIResponsesRequestFromAny(request)
+}
+
+func responsesInputItems(raw []byte) ([]map[string]any, error) {
+ if !rawJSONPresent(raw) {
+ return nil, nil
+ }
+
+ switch common.GetJsonType(raw) {
+ case "string":
+ input, err := responsesJSONString(raw)
+ if err != nil {
+ return nil, fmt.Errorf("invalid input string: %w", err)
+ }
+ return []map[string]any{
+ {
+ "role": "user",
+ "content": input,
+ },
+ }, nil
+ case "array":
+ var items []map[string]any
+ if err := common.Unmarshal(raw, &items); err != nil {
+ return nil, fmt.Errorf("invalid input array: %w", err)
+ }
+ return items, nil
+ default:
+ return nil, fmt.Errorf("unsupported responses input type %q", common.GetJsonType(raw))
+ }
+}
+
+func InputItems(raw []byte) ([]map[string]any, error) {
+ return responsesInputItems(raw)
+}
+
+func responsesContentParts(content any) ([]map[string]any, error) {
+ switch typed := content.(type) {
+ case nil:
+ return nil, nil
+ case string:
+ return []map[string]any{{"type": "input_text", "text": typed}}, nil
+ case []map[string]any:
+ return typed, nil
+ case []any:
+ parts := make([]map[string]any, 0, len(typed))
+ for _, item := range typed {
+ switch part := item.(type) {
+ case string:
+ parts = append(parts, map[string]any{"type": "input_text", "text": part})
+ case map[string]any:
+ parts = append(parts, part)
+ default:
+ raw, err := common.Marshal(part)
+ if err != nil {
+ return nil, err
+ }
+ parts = append(parts, map[string]any{"type": "input_text", "text": string(raw)})
+ }
+ }
+ return parts, nil
+ default:
+ raw, err := common.Marshal(typed)
+ if err != nil {
+ return nil, err
+ }
+ return []map[string]any{{"type": "input_text", "text": string(raw)}}, nil
+ }
+}
+
+func ContentParts(content any) ([]map[string]any, error) {
+ return responsesContentParts(content)
+}
+
+func responsesRequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
+ if !rawJSONPresent(raw) {
+ return nil, nil
+ }
+
+ var tools []map[string]any
+ if err := common.Unmarshal(raw, &tools); err != nil {
+ return nil, fmt.Errorf("invalid tools: %w", err)
+ }
+
+ functions := make([]dto.FunctionRequest, 0, len(tools))
+ for _, tool := range tools {
+ if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
+ continue
+ }
+ name := strings.TrimSpace(common.Interface2String(tool["name"]))
+ if name == "" {
+ continue
+ }
+ functions = append(functions, dto.FunctionRequest{
+ Name: name,
+ Description: common.Interface2String(tool["description"]),
+ Parameters: tool["parameters"],
+ })
+ }
+ return functions, nil
+}
+
+func RequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
+ return responsesRequestFunctionDeclarations(raw)
+}
+
+func responsesReasoningEffort(req *dto.OpenAIResponsesRequest) string {
+ if req == nil || req.Reasoning == nil {
+ return ""
+ }
+ return req.Reasoning.Effort
+}
+
+func ReasoningEffort(req *dto.OpenAIResponsesRequest) string {
+ return responsesReasoningEffort(req)
+}
+
+func responsesObjectValue(value any, fallbackKey string) map[string]any {
+ switch typed := value.(type) {
+ case nil:
+ return map[string]any{}
+ case map[string]any:
+ return typed
+ case string:
+ var object map[string]any
+ if err := common.Unmarshal([]byte(typed), &object); err == nil {
+ return object
+ }
+ var array []any
+ if err := common.Unmarshal([]byte(typed), &array); err == nil {
+ return map[string]any{fallbackKey: array}
+ }
+ return map[string]any{fallbackKey: typed}
+ case []any:
+ return map[string]any{fallbackKey: typed}
+ default:
+ return map[string]any{fallbackKey: typed}
+ }
+}
+
+func ObjectValue(value any, fallbackKey string) map[string]any {
+ return responsesObjectValue(value, fallbackKey)
+}
+
+func responsesGeminiResponseMap(value any) map[string]interface{} {
+ switch typed := value.(type) {
+ case nil:
+ return map[string]interface{}{}
+ case map[string]any:
+ return typed
+ case string:
+ var object map[string]interface{}
+ if err := common.Unmarshal([]byte(typed), &object); err == nil {
+ return object
+ }
+ var array []interface{}
+ if err := common.Unmarshal([]byte(typed), &array); err == nil {
+ return map[string]interface{}{"result": array}
+ }
+ return map[string]interface{}{"content": typed}
+ case []any:
+ return map[string]interface{}{"result": typed}
+ default:
+ return map[string]interface{}{"content": typed}
+ }
+}
+
+func GeminiResponseMap(value any) map[string]interface{} {
+ return responsesGeminiResponseMap(value)
+}
+
+func responsesParallelToolCalls(raw []byte) *bool {
+ if !rawJSONPresent(raw) || common.GetJsonType(raw) != "boolean" {
+ return nil
+ }
+ var parallelToolCalls bool
+ if err := common.Unmarshal(raw, ¶llelToolCalls); err != nil {
+ return nil
+ }
+ return ¶llelToolCalls
+}
+
+func ParallelToolCalls(raw []byte) *bool {
+ return responsesParallelToolCalls(raw)
+}
+
+func ContentPartToFileSource(part map[string]any) types.FileSource {
+ partType := strings.TrimSpace(common.Interface2String(part["type"]))
+ var data string
+ var mimeType string
+
+ switch partType {
+ case "input_image":
+ data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
+ case "input_file":
+ data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
+ case "input_audio":
+ data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
+ if mimeType == "" {
+ if payload, ok := part["input_audio"].(map[string]any); ok {
+ if format := strings.TrimSpace(common.Interface2String(payload["format"])); format != "" {
+ mimeType = "audio/" + format
+ }
+ }
+ }
+ case "input_video":
+ data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
+ }
+ if data == "" {
+ return nil
+ }
+ return types.NewFileSourceFromData(data, mimeType)
+}
+
+func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
+ mimeType := strings.TrimSpace(common.Interface2String(part["mime_type"]))
+ for _, key := range keys {
+ value, ok := part[key]
+ if !ok {
+ continue
+ }
+ switch typed := value.(type) {
+ case string:
+ if typed != "" {
+ return typed, mimeType
+ }
+ case map[string]any:
+ if mimeType == "" {
+ mimeType = strings.TrimSpace(common.Interface2String(typed["mime_type"]))
+ }
+ for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
+ if data := strings.TrimSpace(common.Interface2String(typed[nestedKey])); data != "" {
+ return data, mimeType
+ }
+ }
+ }
+ }
+ return "", mimeType
+}
diff --git a/service/relayconvert/internal/oai_responses/to_claude_messages_req.go b/service/relayconvert/internal/oai_responses/to_claude_messages_req.go
new file mode 100644
index 000000000000..d376dce7cc7d
--- /dev/null
+++ b/service/relayconvert/internal/oai_responses/to_claude_messages_req.go
@@ -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...)
+}
diff --git a/service/relayconvert/internal/oai_responses/to_gemini_chat_req.go b/service/relayconvert/internal/oai_responses/to_gemini_chat_req.go
new file mode 100644
index 000000000000..8810e98735cc
--- /dev/null
+++ b/service/relayconvert/internal/oai_responses/to_gemini_chat_req.go
@@ -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"
+ }
+}
diff --git a/relay/channel/gemini/adaptor_responses.go b/service/relayconvert/internal/oai_responses/to_gemini_chat_req_preprocess.go
similarity index 87%
rename from relay/channel/gemini/adaptor_responses.go
rename to service/relayconvert/internal/oai_responses/to_gemini_chat_req_preprocess.go
index f03c996f58f2..71d9645125b3 100644
--- a/relay/channel/gemini/adaptor_responses.go
+++ b/service/relayconvert/internal/oai_responses/to_gemini_chat_req_preprocess.go
@@ -1,4 +1,4 @@
-package gemini
+package oairesponses
import (
"strings"
@@ -13,7 +13,11 @@ const (
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)
if err != nil {
return request, err
@@ -42,7 +46,6 @@ func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
filtered := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
if strings.TrimSpace(common.Interface2String(tool["type"])) != "function" {
- // TODO: Support Responses custom/freeform tools when Gemini has a safe equivalent representation.
continue
}
filtered = append(filtered, tool)
@@ -78,7 +81,6 @@ func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
itemType := strings.TrimSpace(common.Interface2String(item["type"]))
switch itemType {
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
- // TODO: Support Responses custom/freeform tool calls once Gemini can preserve their semantics.
continue
case geminiResponsesInputTypeFunctionCallOutput:
if _, ok := skippedCustomCallIDs[strings.TrimSpace(common.Interface2String(item["call_id"]))]; ok {
diff --git a/service/relayconvert/responses_request_to_chat.go b/service/relayconvert/internal/oai_responses/to_oai_chat_req.go
similarity index 93%
rename from service/relayconvert/responses_request_to_chat.go
rename to service/relayconvert/internal/oai_responses/to_oai_chat_req.go
index 076d79afeaa8..7779364afd8b 100644
--- a/service/relayconvert/responses_request_to_chat.go
+++ b/service/relayconvert/internal/oai_responses/to_oai_chat_req.go
@@ -1,4 +1,4 @@
-package relayconvert
+package oairesponses
import (
"encoding/json"
@@ -14,6 +14,14 @@ const (
responsesInputTypeFunctionCall = "function_call"
responsesInputTypeFunctionCallOutput = "function_call_output"
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) {
@@ -109,6 +117,10 @@ func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesReque
return nil
}
+func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
+ return validateResponsesRequestChatUnsupportedFields(req)
+}
+
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
messages := make([]dto.Message, 0)
if rawJSONPresent(req.Instructions) {
@@ -373,6 +385,10 @@ func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
return choice, nil
}
+func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
+ return responsesRequestToolChoiceToChat(raw)
+}
+
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
if !rawJSONPresent(raw) {
return nil, nil
@@ -403,6 +419,10 @@ func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.Respons
return out, nil
}
+func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
+ return responsesRequestTextToChatResponseFormat(raw)
+}
+
func responsesImagePartToChatImageURL(part map[string]any) any {
if imageURL, ok := part["image_url"]; ok {
return imageURL
@@ -472,6 +492,10 @@ func responsesCallID(item map[string]any) string {
return strings.TrimSpace(common.Interface2String(item["id"]))
}
+func CallID(item map[string]any) string {
+ return responsesCallID(item)
+}
+
func responsesArgumentsString(value any) string {
switch v := value.(type) {
case nil:
@@ -519,3 +543,11 @@ func rawJSONPresent(raw json.RawMessage) bool {
}
return common.GetJsonType(raw) != "null"
}
+
+func JSONString(raw json.RawMessage) (string, error) {
+ return responsesJSONString(raw)
+}
+
+func RawJSONPresent(raw json.RawMessage) bool {
+ return rawJSONPresent(raw)
+}
diff --git a/service/relayconvert/responses_request_to_chat_test.go b/service/relayconvert/internal/oai_responses/to_oai_chat_req_test.go
similarity index 99%
rename from service/relayconvert/responses_request_to_chat_test.go
rename to service/relayconvert/internal/oai_responses/to_oai_chat_req_test.go
index 1e8f5e79a8d7..6924c82a6ed9 100644
--- a/service/relayconvert/responses_request_to_chat_test.go
+++ b/service/relayconvert/internal/oai_responses/to_oai_chat_req_test.go
@@ -1,4 +1,4 @@
-package relayconvert
+package oairesponses
import (
"testing"
diff --git a/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go b/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go
new file mode 100644
index 000000000000..d8a3f3f72035
--- /dev/null
+++ b/service/relayconvert/internal/oai_responses/to_oai_chat_resp.go
@@ -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 ""
+}
diff --git a/service/relayconvert/chat_responses_compat_test.go b/service/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go
similarity index 65%
rename from service/relayconvert/chat_responses_compat_test.go
rename to service/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go
index b14754d6bdfb..645827ea0d7c 100644
--- a/service/relayconvert/chat_responses_compat_test.go
+++ b/service/relayconvert/internal/oai_responses/to_oai_chat_resp_test.go
@@ -1,51 +1,13 @@
-package relayconvert
+package oairesponses
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 TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
@@ -413,144 +375,6 @@ func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexA
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 {
state := NewResponsesToChatStreamState("gpt-test", false)
state.ID = "chatcmpl_test"
@@ -564,10 +388,3 @@ func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dt
require.NoError(t, err)
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
-}
diff --git a/service/relayconvert/responses_to_chat.go b/service/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go
similarity index 73%
rename from service/relayconvert/responses_to_chat.go
rename to service/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go
index bfa1621189e5..0b5f35333e9d 100644
--- a/service/relayconvert/responses_to_chat.go
+++ b/service/relayconvert/internal/oai_responses/to_oai_chat_stream_resp.go
@@ -1,7 +1,6 @@
-package relayconvert
+package oairesponses
import (
- "errors"
"fmt"
"sort"
"strings"
@@ -11,207 +10,6 @@ import (
"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 {
ID string
Model string
@@ -902,64 +700,3 @@ func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamR
}
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 ""
-}
diff --git a/service/relayconvert/internal/shared/claude/cache.go b/service/relayconvert/internal/shared/claude/cache.go
new file mode 100644
index 000000000000..98538e442e49
--- /dev/null
+++ b/service/relayconvert/internal/shared/claude/cache.go
@@ -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
+}
diff --git a/service/relayconvert/internal/shared/claude/tool_choice.go b/service/relayconvert/internal/shared/claude/tool_choice.go
new file mode 100644
index 000000000000..88ba30612e8c
--- /dev/null
+++ b/service/relayconvert/internal/shared/claude/tool_choice.go
@@ -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
+}
diff --git a/service/relayconvert/internal/shared/gemini/request.go b/service/relayconvert/internal/shared/gemini/request.go
new file mode 100644
index 000000000000..795b1375bbba
--- /dev/null
+++ b/service/relayconvert/internal/shared/gemini/request.go
@@ -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)
+}
diff --git a/service/relayconvert/internal/shared/gemini/schema.go b/service/relayconvert/internal/shared/gemini/schema.go
new file mode 100644
index 000000000000..692380fd72d7
--- /dev/null
+++ b/service/relayconvert/internal/shared/gemini/schema.go
@@ -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
+}
diff --git a/service/relayconvert/media.go b/service/relayconvert/media.go
new file mode 100644
index 000000000000..d175e7e6ae52
--- /dev/null
+++ b/service/relayconvert/media.go
@@ -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)
+}
diff --git a/service/relayconvert/request_compat.go b/service/relayconvert/request_compat.go
new file mode 100644
index 000000000000..f2f77ae980c2
--- /dev/null
+++ b/service/relayconvert/request_compat.go
@@ -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)
+}
diff --git a/service/relayconvert/request_registry.go b/service/relayconvert/request_registry.go
new file mode 100644
index 000000000000..c3b6eeedc3b5
--- /dev/null
+++ b/service/relayconvert/request_registry.go
@@ -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)
+}
diff --git a/service/relayconvert/request_registry_test.go b/service/relayconvert/request_registry_test.go
new file mode 100644
index 000000000000..7785b69b66c1
--- /dev/null
+++ b/service/relayconvert/request_registry_test.go
@@ -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
+}
diff --git a/service/relayconvert/response_compat.go b/service/relayconvert/response_compat.go
new file mode 100644
index 000000000000..b42bed42f717
--- /dev/null
+++ b/service/relayconvert/response_compat.go
@@ -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()
+}
diff --git a/service/relayconvert/response_registry.go b/service/relayconvert/response_registry.go
new file mode 100644
index 000000000000..43ef9d38aa9f
--- /dev/null
+++ b/service/relayconvert/response_registry.go
@@ -0,0 +1,1047 @@
+package relayconvert
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+ "strings"
+ "sync"
+
+ "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/types"
+ "github.com/gin-gonic/gin"
+)
+
+type ResponseConverterFunc func(c *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error)
+
+type ResponseStreamConverterFunc func(c *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error)
+
+type ResponseStreamStateFactory func(options ResponseStreamOptions) any
+
+type ResponseStreamChunkConverterFunc func(c *gin.Context, info *relaycommon.RelayInfo, response any, state any) ([]any, *dto.Usage, error)
+
+type ResponseStreamFinalizerFunc func(c *gin.Context, info *relaycommon.RelayInfo, state any) ([]any, *dto.Usage, error)
+
+type ResponseConverterQuality string
+
+const (
+ ResponseConverterQualityGood ResponseConverterQuality = "good"
+ ResponseConverterQualityFair ResponseConverterQuality = "fair"
+ ResponseConverterQualityDiscouraged ResponseConverterQuality = "discouraged"
+)
+
+type ResponseStep struct {
+ Converter string
+ From types.RelayFormat
+ To types.RelayFormat
+}
+
+type ResponseResult struct {
+ Value any
+ Usage *dto.Usage
+ From types.RelayFormat
+ To types.RelayFormat
+ Converter string
+ Quality ResponseConverterQuality
+ Steps []ResponseStep
+ Stream bool
+}
+
+type ResponseConverterSpec struct {
+ ID string
+ From types.RelayFormat
+ To types.RelayFormat
+ Quality ResponseConverterQuality
+ Convert ResponseConverterFunc
+ ConvertStream ResponseStreamConverterFunc
+ NewStreamState ResponseStreamStateFactory
+ ConvertStreamChunk ResponseStreamChunkConverterFunc
+ FinalizeStream ResponseStreamFinalizerFunc
+ StepConverters []string
+}
+
+type responseConverterRoute struct {
+ from types.RelayFormat
+ to types.RelayFormat
+}
+
+type ResponseStreamOptions struct {
+ ID string
+ Model string
+ Created int64
+ IncludeUsage bool
+}
+
+type ResponseStreamState struct {
+ From types.RelayFormat
+ To types.RelayFormat
+ Converter string
+ Quality ResponseConverterQuality
+ Steps []ResponseStep
+
+ specs []ResponseConverterSpec
+ stepStates []any
+ usage *dto.Usage
+}
+
+const (
+ ResponseConverterOAIChatToOAIResponses = "oai_chat_to_oai_responses_resp"
+ ResponseConverterOAIResponsesToOAIChat = "oai_responses_to_oai_chat_resp"
+ ResponseConverterOAIChatToClaudeMessages = "oai_chat_to_claude_messages_resp"
+ ResponseConverterOAIChatToGeminiChat = "oai_chat_to_gemini_chat_resp"
+ ResponseConverterClaudeMessagesToOAIChat = "claude_messages_to_oai_chat_resp"
+ ResponseConverterGeminiChatToOAIChat = "gemini_chat_to_oai_chat_resp"
+
+ responseConverterClaudeToGemini = "claude_messages_to_gemini_chat_resp"
+ responseConverterClaudeToResponses = "claude_messages_to_oai_responses_resp"
+ responseConverterGeminiToClaude = "gemini_chat_to_claude_messages_resp"
+ responseConverterGeminiToResponses = "gemini_chat_to_oai_responses_resp"
+ responseConverterResponsesToClaude = "oai_responses_to_claude_messages_resp"
+ responseConverterResponsesToGemini = "oai_responses_to_gemini_chat_resp"
+)
+
+var (
+ responseConverterMu sync.RWMutex
+ responseConverters = make(map[string]ResponseConverterSpec)
+ responseConverterAliases = make(map[string]string)
+ responseConverterRoutes = make(map[responseConverterRoute]string)
+)
+
+func registerBuiltinResponseConverter(spec ResponseConverterSpec) {
+ spec.ID = strings.TrimSpace(spec.ID)
+ if spec.ID == "" {
+ panic("response converter ID is required")
+ }
+ if spec.From == "" || spec.To == "" {
+ panic(fmt.Sprintf("response converter %q must declare from and to formats", spec.ID))
+ }
+ if spec.Quality == "" {
+ panic(fmt.Sprintf("response converter %q must declare quality", spec.ID))
+ }
+ if spec.Convert == nil &&
+ spec.ConvertStream == nil &&
+ spec.ConvertStreamChunk == nil &&
+ len(spec.StepConverters) == 0 {
+ panic(fmt.Sprintf("response converter %q must declare convert, stream convert, or step converters", spec.ID))
+ }
+ if len(spec.StepConverters) > 0 &&
+ (spec.Convert != nil || spec.ConvertStream != nil || spec.NewStreamState != nil || spec.ConvertStreamChunk != nil || spec.FinalizeStream != nil) {
+ panic(fmt.Sprintf("response converter %q cannot declare direct implementations and step converters together", spec.ID))
+ }
+ if _, exists := responseConverters[spec.ID]; exists {
+ panic(fmt.Sprintf("response converter %q is already registered", spec.ID))
+ }
+ route := responseConverterRoute{from: spec.From, to: spec.To}
+ if existingID, exists := responseConverterRoutes[route]; exists {
+ panic(fmt.Sprintf("response 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 := responseConverters[converterID]
+ if !ok {
+ panic(fmt.Sprintf("response converter %q references unknown step converter %q", spec.ID, converterID))
+ }
+ if len(step.StepConverters) > 0 {
+ panic(fmt.Sprintf("response converter %q step %q must be a direct converter", spec.ID, converterID))
+ }
+ if step.From != current {
+ panic(fmt.Sprintf("response 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("response converter %q ends at %s, expected %s", spec.ID, current, spec.To))
+ }
+ spec.StepConverters = stepConverters
+ }
+
+ responseConverters[spec.ID] = spec
+ responseConverterRoutes[route] = spec.ID
+}
+
+func registerResponseConverterAlias(alias string, converter string) {
+ alias = strings.TrimSpace(alias)
+ converter = strings.TrimSpace(converter)
+ if alias == "" {
+ panic("response converter alias is required")
+ }
+ if converter == "" {
+ panic(fmt.Sprintf("response converter alias %q target is required", alias))
+ }
+ if alias == converter {
+ return
+ }
+ if _, exists := responseConverters[alias]; exists {
+ panic(fmt.Sprintf("response converter alias %q conflicts with registered converter", alias))
+ }
+ if _, exists := responseConverters[converter]; !exists {
+ panic(fmt.Sprintf("response converter alias %q references unknown converter %q", alias, converter))
+ }
+ if existing, exists := responseConverterAliases[alias]; exists && existing != converter {
+ panic(fmt.Sprintf("response converter alias %q is already registered for %q", alias, existing))
+ }
+ responseConverterAliases[alias] = converter
+}
+
+func LookupResponseConverter(converter string) (ResponseConverterSpec, bool) {
+ responseConverterMu.RLock()
+ defer responseConverterMu.RUnlock()
+
+ converterID := resolveResponseConverterID(converter)
+ spec, ok := responseConverters[converterID]
+ if !ok {
+ return ResponseConverterSpec{}, false
+ }
+ return cloneResponseConverterSpec(spec), true
+}
+
+func ConvertResponse(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, response any) (*ResponseResult, error) {
+ from, err := inferResponseRelayFormat(response)
+ if err != nil {
+ return nil, err
+ }
+ if target == "" {
+ return nil, errors.New("target relay format is required")
+ }
+ if from == target {
+ return &ResponseResult{
+ Value: response,
+ Usage: canonicalUsageFromResponse(response),
+ From: from,
+ To: target,
+ Stream: false,
+ }, nil
+ }
+
+ spec, ok := lookupResponseRoute(from, target)
+ if !ok {
+ return nil, fmt.Errorf("response converter from %s to %s is not registered", from, target)
+ }
+ return executeResponseSpec(c, info, from, target, response, spec)
+}
+
+func ConvertResponseByID(c *gin.Context, info *relaycommon.RelayInfo, converter string, response any) (*ResponseResult, error) {
+ from, err := inferResponseRelayFormat(response)
+ if err != nil {
+ return nil, err
+ }
+
+ spec, ok := LookupResponseConverter(converter)
+ if !ok {
+ return nil, fmt.Errorf("response converter %q is not registered", strings.TrimSpace(converter))
+ }
+ if spec.From != "" && spec.From != from {
+ return nil, fmt.Errorf("response converter %q expects %s response, got %s", spec.ID, spec.From, from)
+ }
+ return executeResponseSpec(c, info, from, spec.To, response, spec)
+}
+
+func ConvertStreamResponse(c *gin.Context, info *relaycommon.RelayInfo, target types.RelayFormat, response any) (*ResponseResult, error) {
+ from, err := inferResponseRelayFormat(response)
+ if err != nil {
+ return nil, err
+ }
+ if target == "" {
+ return nil, errors.New("target relay format is required")
+ }
+ if from == target {
+ return &ResponseResult{
+ Value: response,
+ Usage: canonicalUsageFromResponse(response),
+ From: from,
+ To: target,
+ Stream: true,
+ }, nil
+ }
+
+ spec, ok := lookupResponseRoute(from, target)
+ if !ok {
+ return nil, fmt.Errorf("response converter from %s to %s is not registered", from, target)
+ }
+ return executeStatelessStreamResponseSpec(c, info, from, target, response, spec)
+}
+
+func NewResponseStreamState(from types.RelayFormat, target types.RelayFormat, options ResponseStreamOptions) (*ResponseStreamState, error) {
+ if from == "" {
+ return nil, errors.New("source relay format is required")
+ }
+ if target == "" {
+ return nil, errors.New("target relay format is required")
+ }
+ if from == target {
+ return &ResponseStreamState{
+ From: from,
+ To: target,
+ }, nil
+ }
+
+ spec, ok := lookupResponseRoute(from, target)
+ if !ok {
+ return nil, fmt.Errorf("response converter from %s to %s is not registered", from, target)
+ }
+ return newResponseStreamStateFromSpec(from, target, options, spec)
+}
+
+func NewResponseStreamStateByID(converter string, options ResponseStreamOptions) (*ResponseStreamState, error) {
+ spec, ok := LookupResponseConverter(converter)
+ if !ok {
+ return nil, fmt.Errorf("response converter %q is not registered", strings.TrimSpace(converter))
+ }
+ return newResponseStreamStateFromSpec(spec.From, spec.To, options, spec)
+}
+
+func ConvertStreamResponseChunk(c *gin.Context, info *relaycommon.RelayInfo, state *ResponseStreamState, response any) ([]ResponseResult, error) {
+ if state == nil {
+ return nil, errors.New("response stream state is required")
+ }
+ from, err := inferResponseRelayFormat(response)
+ if err != nil {
+ return nil, err
+ }
+ if from != state.From {
+ return nil, fmt.Errorf("response stream converter %q expects %s response, got %s", state.Converter, state.From, from)
+ }
+ if state.From == state.To {
+ usage := canonicalUsageFromResponse(response)
+ state.rememberUsage(usage)
+ return responseStreamResults(state, streamValuesFromAny(response), usage), nil
+ }
+
+ values, usage, err := executeResponseStreamSteps(c, info, state, []any{response}, 0)
+ if err != nil {
+ return nil, err
+ }
+ state.rememberUsage(usage)
+ return responseStreamResults(state, values, usage), nil
+}
+
+func FinalizeStreamResponse(c *gin.Context, info *relaycommon.RelayInfo, state *ResponseStreamState) ([]ResponseResult, error) {
+ if state == nil {
+ return nil, errors.New("response stream state is required")
+ }
+ if state.From == state.To {
+ return nil, nil
+ }
+
+ values := make([]any, 0)
+ var usage *dto.Usage
+ for i, spec := range state.specs {
+ finalValues, stepUsage, err := finalizeResponseStreamStep(c, info, spec, state.stepStates[i])
+ if err != nil {
+ return nil, err
+ }
+ if stepUsage != nil {
+ usage = stepUsage
+ state.rememberUsage(stepUsage)
+ }
+ if len(finalValues) == 0 {
+ continue
+ }
+ current, currentUsage, err := executeResponseStreamSteps(c, info, state, finalValues, i+1)
+ if err != nil {
+ return nil, err
+ }
+ if currentUsage != nil {
+ usage = currentUsage
+ state.rememberUsage(currentUsage)
+ }
+ values = append(values, current...)
+ }
+ return responseStreamResults(state, values, usage), nil
+}
+
+func (s *ResponseStreamState) Usage() *dto.Usage {
+ if s == nil {
+ return nil
+ }
+ if s.usage != nil {
+ return s.usage
+ }
+ for _, state := range s.stepStates {
+ switch typed := state.(type) {
+ case *ChatToResponsesStreamState:
+ if typed.Usage != nil {
+ return typed.Usage
+ }
+ case *ResponsesToChatStreamState:
+ if typed.Usage != nil {
+ return typed.Usage
+ }
+ }
+ }
+ return nil
+}
+
+func (s *ResponseStreamState) SetUsage(usage *dto.Usage) {
+ if s == nil || usage == nil {
+ return
+ }
+ s.usage = usage
+ for _, state := range s.stepStates {
+ switch typed := state.(type) {
+ case *ChatToResponsesStreamState:
+ typed.Usage = UsageFromChatUsage(usage)
+ case *ResponsesToChatStreamState:
+ typed.Usage = usage
+ }
+ }
+}
+
+func (s *ResponseStreamState) UsageText() string {
+ if s == nil {
+ return ""
+ }
+ for _, state := range s.stepStates {
+ switch typed := state.(type) {
+ case interface{ UsageText() string }:
+ if text := typed.UsageText(); text != "" {
+ return text
+ }
+ }
+ }
+ return ""
+}
+
+func executeResponseSpec(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, response any, spec ResponseConverterSpec) (*ResponseResult, error) {
+ steps, err := expandResponseConverterSteps(spec)
+ if err != nil {
+ return nil, err
+ }
+ return executeResponseSteps(c, info, from, target, response, spec.ID, spec.Quality, steps)
+}
+
+func executeResponseSteps(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, response any, converter string, quality ResponseConverterQuality, specs []ResponseConverterSpec) (*ResponseResult, error) {
+ current := response
+ var usage *dto.Usage
+ steps := make([]ResponseStep, 0, len(specs))
+ for _, spec := range specs {
+ var step ResponseStep
+ var err error
+ current, usage, step, err = executeResponseStep(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 &ResponseResult{
+ Value: current,
+ Usage: usage,
+ From: from,
+ To: target,
+ Converter: converter,
+ Quality: quality,
+ Steps: steps,
+ Stream: false,
+ }, nil
+}
+
+func executeResponseStep(c *gin.Context, info *relaycommon.RelayInfo, spec ResponseConverterSpec, response any) (any, *dto.Usage, ResponseStep, error) {
+ if spec.Convert == nil {
+ return nil, nil, ResponseStep{}, fmt.Errorf("response converter %q has no non-stream implementation", spec.ID)
+ }
+
+ value, usage, err := spec.Convert(c, info, response)
+ if err != nil {
+ return nil, nil, ResponseStep{}, err
+ }
+ return value, usage, ResponseStep{
+ Converter: spec.ID,
+ From: spec.From,
+ To: spec.To,
+ }, nil
+}
+
+func executeStatelessStreamResponseSpec(c *gin.Context, info *relaycommon.RelayInfo, from types.RelayFormat, target types.RelayFormat, response any, spec ResponseConverterSpec) (*ResponseResult, error) {
+ steps, err := expandResponseConverterSteps(spec)
+ if err != nil {
+ return nil, err
+ }
+ current := response
+ var usage *dto.Usage
+ resultSteps := make([]ResponseStep, 0, len(steps))
+ for _, step := range steps {
+ if step.ConvertStreamChunk != nil || step.NewStreamState != nil || step.FinalizeStream != nil {
+ return nil, fmt.Errorf("response converter %q requires response stream state", step.ID)
+ }
+ if step.ConvertStream == nil {
+ return nil, fmt.Errorf("response converter %q has no stream implementation", step.ID)
+ }
+ var err error
+ current, usage, err = step.ConvertStream(c, info, current)
+ if err != nil {
+ return nil, err
+ }
+ resultSteps = append(resultSteps, ResponseStep{
+ Converter: step.ID,
+ From: step.From,
+ To: step.To,
+ })
+ }
+ return &ResponseResult{
+ Value: current,
+ Usage: usage,
+ From: from,
+ To: target,
+ Converter: spec.ID,
+ Quality: spec.Quality,
+ Steps: resultSteps,
+ Stream: true,
+ }, nil
+}
+
+func newResponseStreamStateFromSpec(from types.RelayFormat, target types.RelayFormat, options ResponseStreamOptions, spec ResponseConverterSpec) (*ResponseStreamState, error) {
+ steps, err := expandResponseConverterSteps(spec)
+ if err != nil {
+ return nil, err
+ }
+ stepStates := make([]any, len(steps))
+ resultSteps := make([]ResponseStep, 0, len(steps))
+ for i, step := range steps {
+ if step.NewStreamState != nil {
+ stepStates[i] = step.NewStreamState(options)
+ }
+ resultSteps = append(resultSteps, ResponseStep{
+ Converter: step.ID,
+ From: step.From,
+ To: step.To,
+ })
+ }
+ return &ResponseStreamState{
+ From: from,
+ To: target,
+ Converter: spec.ID,
+ Quality: spec.Quality,
+ Steps: resultSteps,
+ specs: steps,
+ stepStates: stepStates,
+ }, nil
+}
+
+func executeResponseStreamSteps(c *gin.Context, info *relaycommon.RelayInfo, state *ResponseStreamState, values []any, start int) ([]any, *dto.Usage, error) {
+ current := values
+ var usage *dto.Usage
+ for i := start; i < len(state.specs); i++ {
+ spec := state.specs[i]
+ next := make([]any, 0)
+ for _, value := range current {
+ prepareResponseStreamInfo(info, spec)
+ stepValues, stepUsage, err := executeResponseStreamStep(c, info, spec, state.stepStates[i], value)
+ if err != nil {
+ return nil, nil, err
+ }
+ if stepUsage != nil {
+ usage = stepUsage
+ state.rememberUsage(stepUsage)
+ }
+ next = append(next, stepValues...)
+ }
+ current = next
+ if len(current) == 0 {
+ return nil, usage, nil
+ }
+ }
+ return current, usage, nil
+}
+
+func prepareResponseStreamInfo(info *relaycommon.RelayInfo, spec ResponseConverterSpec) {
+ if info == nil {
+ return
+ }
+ if spec.From != types.RelayFormatOpenAI {
+ return
+ }
+ if spec.To != types.RelayFormatClaude && spec.To != types.RelayFormatGemini {
+ return
+ }
+ info.SendResponseCount++
+}
+
+func executeResponseStreamStep(c *gin.Context, info *relaycommon.RelayInfo, spec ResponseConverterSpec, state any, response any) ([]any, *dto.Usage, error) {
+ if spec.ConvertStreamChunk != nil {
+ return spec.ConvertStreamChunk(c, info, response, state)
+ }
+ if spec.ConvertStream == nil {
+ return nil, nil, fmt.Errorf("response converter %q has no stream implementation", spec.ID)
+ }
+ value, usage, err := spec.ConvertStream(c, info, response)
+ if err != nil {
+ return nil, nil, err
+ }
+ return streamValuesFromAny(value), usage, nil
+}
+
+func finalizeResponseStreamStep(c *gin.Context, info *relaycommon.RelayInfo, spec ResponseConverterSpec, state any) ([]any, *dto.Usage, error) {
+ if spec.FinalizeStream == nil {
+ return nil, nil, nil
+ }
+ return spec.FinalizeStream(c, info, state)
+}
+
+func (s *ResponseStreamState) rememberUsage(usage *dto.Usage) {
+ if s != nil && usage != nil {
+ s.usage = usage
+ }
+}
+
+func responseStreamResults(state *ResponseStreamState, values []any, usage *dto.Usage) []ResponseResult {
+ if state == nil || len(values) == 0 {
+ return nil
+ }
+ results := make([]ResponseResult, 0, len(values))
+ for _, value := range values {
+ results = append(results, ResponseResult{
+ Value: value,
+ Usage: usage,
+ From: state.From,
+ To: state.To,
+ Converter: state.Converter,
+ Quality: state.Quality,
+ Steps: append([]ResponseStep{}, state.Steps...),
+ Stream: true,
+ })
+ }
+ return results
+}
+
+func streamValuesFromAny(value any) []any {
+ if value == nil {
+ return nil
+ }
+ rv := reflect.ValueOf(value)
+ if rv.Kind() == reflect.Pointer && rv.IsNil() {
+ return nil
+ }
+ if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
+ return []any{value}
+ }
+ if rv.Type().Elem().Kind() == reflect.Uint8 {
+ return []any{value}
+ }
+ values := make([]any, 0, rv.Len())
+ for i := 0; i < rv.Len(); i++ {
+ item := rv.Index(i)
+ if item.Kind() == reflect.Pointer && item.IsNil() {
+ continue
+ }
+ values = append(values, item.Interface())
+ }
+ return values
+}
+
+func expandResponseConverterSteps(spec ResponseConverterSpec) ([]ResponseConverterSpec, error) {
+ if len(spec.StepConverters) == 0 {
+ if spec.Convert == nil && spec.ConvertStream == nil && spec.ConvertStreamChunk == nil {
+ return nil, fmt.Errorf("response converter %q has no registered implementation", spec.ID)
+ }
+ return []ResponseConverterSpec{spec}, nil
+ }
+
+ steps := make([]ResponseConverterSpec, 0, len(spec.StepConverters))
+ current := spec.From
+ for _, converterID := range spec.StepConverters {
+ step, ok := LookupResponseConverter(converterID)
+ if !ok {
+ return nil, fmt.Errorf("response converter %q references missing step converter %q", spec.ID, converterID)
+ }
+ if len(step.StepConverters) > 0 {
+ return nil, fmt.Errorf("response converter %q step %q is not a direct converter", spec.ID, converterID)
+ }
+ if step.From != current {
+ return nil, fmt.Errorf("response converter %q step %q expects %s response, got %s", spec.ID, converterID, step.From, current)
+ }
+ steps = append(steps, step)
+ current = step.To
+ }
+ if current != spec.To {
+ return nil, fmt.Errorf("response converter %q ends at %s, expected %s", spec.ID, current, spec.To)
+ }
+ return steps, nil
+}
+
+func lookupResponseRoute(from types.RelayFormat, to types.RelayFormat) (ResponseConverterSpec, bool) {
+ responseConverterMu.RLock()
+ defer responseConverterMu.RUnlock()
+
+ converterID, ok := responseConverterRoutes[responseConverterRoute{from: from, to: to}]
+ if !ok {
+ return ResponseConverterSpec{}, false
+ }
+ spec, ok := responseConverters[converterID]
+ return cloneResponseConverterSpec(spec), ok
+}
+
+func resolveResponseConverterID(converter string) string {
+ converter = strings.TrimSpace(converter)
+ if canonical, ok := responseConverterAliases[converter]; ok {
+ return canonical
+ }
+ return converter
+}
+
+func cloneResponseConverterSpec(spec ResponseConverterSpec) ResponseConverterSpec {
+ if len(spec.StepConverters) > 0 {
+ spec.StepConverters = append([]string{}, spec.StepConverters...)
+ }
+ return spec
+}
+
+func inferResponseRelayFormat(response any) (types.RelayFormat, error) {
+ if isNilResponse(response) {
+ return "", errors.New("response is nil")
+ }
+ switch response.(type) {
+ case *dto.OpenAITextResponse, dto.OpenAITextResponse, *dto.ChatCompletionsStreamResponse, dto.ChatCompletionsStreamResponse:
+ return types.RelayFormatOpenAI, nil
+ case *dto.OpenAIResponsesResponse, dto.OpenAIResponsesResponse, *dto.ResponsesStreamResponse, dto.ResponsesStreamResponse:
+ return types.RelayFormatOpenAIResponses, nil
+ case *dto.ClaudeResponse, dto.ClaudeResponse:
+ return types.RelayFormatClaude, nil
+ case *dto.GeminiChatResponse, dto.GeminiChatResponse:
+ return types.RelayFormatGemini, nil
+ default:
+ return "", fmt.Errorf("unsupported response type %T", response)
+ }
+}
+
+func isNilResponse(response any) bool {
+ if response == nil {
+ return true
+ }
+ value := reflect.ValueOf(response)
+ switch value.Kind() {
+ case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
+ return value.IsNil()
+ default:
+ return false
+ }
+}
+
+func canonicalUsageFromResponse(response any) *dto.Usage {
+ switch resp := response.(type) {
+ case *dto.OpenAITextResponse:
+ return UsageFromChatUsage(&resp.Usage)
+ case dto.OpenAITextResponse:
+ return UsageFromChatUsage(&resp.Usage)
+ case *dto.ChatCompletionsStreamResponse:
+ if resp.Usage == nil {
+ return nil
+ }
+ return UsageFromChatUsage(resp.Usage)
+ case dto.ChatCompletionsStreamResponse:
+ if resp.Usage == nil {
+ return nil
+ }
+ return UsageFromChatUsage(resp.Usage)
+ case *dto.OpenAIResponsesResponse:
+ return UsageFromResponsesUsage(resp.Usage)
+ case dto.OpenAIResponsesResponse:
+ return UsageFromResponsesUsage(resp.Usage)
+ case *dto.ResponsesStreamResponse:
+ if resp.Response == nil {
+ return nil
+ }
+ return UsageFromResponsesUsage(resp.Response.Usage)
+ case dto.ResponsesStreamResponse:
+ if resp.Response == nil {
+ return nil
+ }
+ return UsageFromResponsesUsage(resp.Response.Usage)
+ case *dto.ClaudeResponse:
+ return usageFromClaudeResponse(resp)
+ case dto.ClaudeResponse:
+ return usageFromClaudeResponse(&resp)
+ case *dto.GeminiChatResponse:
+ return UsageFromGeminiMetadata(resp.GetUsageMetadata(), 0)
+ case dto.GeminiChatResponse:
+ return UsageFromGeminiMetadata(resp.GetUsageMetadata(), 0)
+ default:
+ return nil
+ }
+}
+
+func usageFromClaudeResponse(resp *dto.ClaudeResponse) *dto.Usage {
+ if resp == nil {
+ return nil
+ }
+ if resp.Usage != nil {
+ return UsageFromClaudeAPIUsage(resp.Usage)
+ }
+ if resp.Message != nil && resp.Message.Usage != nil {
+ return UsageFromClaudeAPIUsage(resp.Message.Usage)
+ }
+ return nil
+}
+
+func convertOAIChatResponseToOAIResponses(_ *gin.Context, _ *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ id := strings.TrimSpace(chatResponse.Id)
+ if id == "" {
+ id = fmt.Sprintf("resp_%s", common.GetUUID())
+ }
+ return ChatCompletionsResponseToResponsesResponse(chatResponse, id)
+}
+
+func convertOAIResponsesResponseToOAIChat(_ *gin.Context, _ *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ responsesResponse, err := asOAIResponsesResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ id := strings.TrimSpace(responsesResponse.ID)
+ if id == "" {
+ id = fmt.Sprintf("chatcmpl-%s", common.GetUUID())
+ }
+ return ResponsesResponseToChatCompletionsResponse(responsesResponse, id)
+}
+
+func newOAIChatToOAIResponsesStreamState(options ResponseStreamOptions) any {
+ id := strings.TrimSpace(options.ID)
+ if id == "" {
+ id = fmt.Sprintf("resp_%s", common.GetUUID())
+ }
+ state := NewChatToResponsesStreamState(id, strings.TrimSpace(options.Model))
+ if options.Created != 0 {
+ state.Created = options.Created
+ }
+ return state
+}
+
+func convertOAIChatStreamResponseToOAIResponses(_ *gin.Context, _ *relaycommon.RelayInfo, response any, state any) ([]any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatStreamResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ streamState, ok := state.(*ChatToResponsesStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI chat to OAI responses stream state is required")
+ }
+ events, err := ChatCompletionsStreamChunkToResponsesEvents(chatResponse, streamState)
+ if err != nil {
+ return nil, nil, err
+ }
+ return streamValuesFromAny(events), streamState.Usage, nil
+}
+
+func finalizeOAIChatStreamResponseToOAIResponses(_ *gin.Context, _ *relaycommon.RelayInfo, state any) ([]any, *dto.Usage, error) {
+ streamState, ok := state.(*ChatToResponsesStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI chat to OAI responses stream state is required")
+ }
+ events := FinalizeChatCompletionsStreamToResponses(streamState)
+ return streamValuesFromAny(events), streamState.Usage, nil
+}
+
+func newOAIResponsesToOAIChatStreamState(options ResponseStreamOptions) any {
+ state := NewResponsesToChatStreamState(strings.TrimSpace(options.Model), options.IncludeUsage)
+ state.ID = strings.TrimSpace(options.ID)
+ if options.Created != 0 {
+ state.Created = options.Created
+ }
+ return state
+}
+
+func convertOAIResponsesStreamResponseToOAIChat(_ *gin.Context, _ *relaycommon.RelayInfo, response any, state any) ([]any, *dto.Usage, error) {
+ responsesResponse, err := asOAIResponsesStreamResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ streamState, ok := state.(*ResponsesToChatStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI responses to OAI chat stream state is required")
+ }
+ chunks, err := ResponsesStreamEventToChatChunks(responsesResponse, streamState)
+ if err != nil {
+ return nil, nil, err
+ }
+ return streamValuesFromAny(chunks), streamState.Usage, nil
+}
+
+func finalizeOAIResponsesStreamResponseToOAIChat(_ *gin.Context, _ *relaycommon.RelayInfo, state any) ([]any, *dto.Usage, error) {
+ streamState, ok := state.(*ResponsesToChatStreamState)
+ if !ok || streamState == nil {
+ return nil, nil, errors.New("OAI responses to OAI chat stream state is required")
+ }
+ chunks := FinalizeResponsesToChatStream(streamState)
+ return streamValuesFromAny(chunks), streamState.Usage, nil
+}
+
+func convertOAIChatResponseToClaudeMessages(_ *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ return ResponseOpenAI2Claude(chatResponse, info), UsageFromChatUsage(&chatResponse.Usage), nil
+}
+
+func convertOAIChatStreamResponseToClaudeMessages(_ *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatStreamResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ return StreamResponseOpenAI2Claude(chatResponse, info), canonicalUsageFromResponse(chatResponse), nil
+}
+
+func convertOAIChatResponseToGeminiChat(_ *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ return ResponseOpenAI2Gemini(chatResponse, info), UsageFromChatUsage(&chatResponse.Usage), nil
+}
+
+func convertOAIChatStreamResponseToGeminiChat(_ *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ chatResponse, err := asOAIChatStreamResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ return StreamResponseOpenAI2Gemini(chatResponse, info), canonicalUsageFromResponse(chatResponse), nil
+}
+
+func convertClaudeMessagesResponseToOAIChat(_ *gin.Context, _ *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ claudeResponse, err := asClaudeResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ usage := usageFromClaudeResponse(claudeResponse)
+ openAIResponse := ResponseClaude2OpenAI(claudeResponse)
+ if usage != nil {
+ openAIResponse.Usage = *usage
+ }
+ return openAIResponse, usage, nil
+}
+
+func convertClaudeMessagesStreamResponseToOAIChat(_ *gin.Context, _ *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ claudeResponse, err := asClaudeResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ openAIResponse := StreamResponseClaude2OpenAI(claudeResponse)
+ usage := usageFromClaudeResponse(claudeResponse)
+ if openAIResponse != nil && usage != nil {
+ openAIResponse.Usage = usage
+ }
+ return openAIResponse, usage, nil
+}
+
+func convertGeminiChatResponseToOAIChat(_ *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ geminiResponse, err := asGeminiChatResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ usage := UsageFromGeminiMetadata(geminiResponse.GetUsageMetadata(), fallbackPromptTokens(info))
+ openAIResponse := ResponseGeminiChat2OpenAI(fmt.Sprintf("chatcmpl-%s", common.GetUUID()), common.GetTimestamp(), geminiResponse)
+ if info != nil && info.ChannelMeta != nil {
+ openAIResponse.Model = info.UpstreamModelName
+ }
+ if usage != nil {
+ openAIResponse.Usage = *usage
+ }
+ return openAIResponse, usage, nil
+}
+
+func convertGeminiChatStreamResponseToOAIChat(_ *gin.Context, info *relaycommon.RelayInfo, response any) (any, *dto.Usage, error) {
+ geminiResponse, err := asGeminiChatResponse(response)
+ if err != nil {
+ return nil, nil, err
+ }
+ openAIResponse, _ := StreamResponseGeminiChat2OpenAI(geminiResponse)
+ usage := UsageFromGeminiMetadata(geminiResponse.GetUsageMetadata(), fallbackPromptTokens(info))
+ if openAIResponse != nil {
+ openAIResponse.Id = fmt.Sprintf("chatcmpl-%s", common.GetUUID())
+ openAIResponse.Created = common.GetTimestamp()
+ if info != nil && info.ChannelMeta != nil {
+ openAIResponse.Model = info.UpstreamModelName
+ }
+ openAIResponse.Usage = usage
+ }
+ return openAIResponse, usage, nil
+}
+
+func fallbackPromptTokens(info *relaycommon.RelayInfo) int {
+ if info == nil {
+ return 0
+ }
+ return info.GetEstimatePromptTokens()
+}
+
+func asOAIChatResponse(response any) (*dto.OpenAITextResponse, error) {
+ switch resp := response.(type) {
+ case *dto.OpenAITextResponse:
+ return resp, nil
+ case dto.OpenAITextResponse:
+ return &resp, nil
+ default:
+ return nil, fmt.Errorf("expected OAI chat response, got %T", response)
+ }
+}
+
+func asOAIChatStreamResponse(response any) (*dto.ChatCompletionsStreamResponse, error) {
+ switch resp := response.(type) {
+ case *dto.ChatCompletionsStreamResponse:
+ return resp, nil
+ case dto.ChatCompletionsStreamResponse:
+ return &resp, nil
+ default:
+ return nil, fmt.Errorf("expected OAI chat stream response, got %T", response)
+ }
+}
+
+func asOAIResponsesResponse(response any) (*dto.OpenAIResponsesResponse, error) {
+ switch resp := response.(type) {
+ case *dto.OpenAIResponsesResponse:
+ return resp, nil
+ case dto.OpenAIResponsesResponse:
+ return &resp, nil
+ default:
+ return nil, fmt.Errorf("expected OAI responses response, got %T", response)
+ }
+}
+
+func asOAIResponsesStreamResponse(response any) (*dto.ResponsesStreamResponse, error) {
+ switch resp := response.(type) {
+ case *dto.ResponsesStreamResponse:
+ return resp, nil
+ case dto.ResponsesStreamResponse:
+ return &resp, nil
+ default:
+ return nil, fmt.Errorf("expected OAI responses stream response, got %T", response)
+ }
+}
+
+func asClaudeResponse(response any) (*dto.ClaudeResponse, error) {
+ switch resp := response.(type) {
+ case *dto.ClaudeResponse:
+ return resp, nil
+ case dto.ClaudeResponse:
+ return &resp, nil
+ default:
+ return nil, fmt.Errorf("expected Claude messages response, got %T", response)
+ }
+}
+
+func asGeminiChatResponse(response any) (*dto.GeminiChatResponse, error) {
+ switch resp := response.(type) {
+ case *dto.GeminiChatResponse:
+ return resp, nil
+ case dto.GeminiChatResponse:
+ return &resp, nil
+ default:
+ return nil, fmt.Errorf("expected Gemini chat response, got %T", response)
+ }
+}
diff --git a/service/relayconvert/response_registry_test.go b/service/relayconvert/response_registry_test.go
new file mode 100644
index 000000000000..0f058f420cf4
--- /dev/null
+++ b/service/relayconvert/response_registry_test.go
@@ -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
+}
diff --git a/service/relayconvert/text_converter_registry.go b/service/relayconvert/text_converter_registry.go
new file mode 100644
index 000000000000..d8cf8362e8fe
--- /dev/null
+++ b/service/relayconvert/text_converter_registry.go
@@ -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...)
+}
diff --git a/service/relayconvert/text_converter_registry_test.go b/service/relayconvert/text_converter_registry_test.go
new file mode 100644
index 000000000000..c17f666365e6
--- /dev/null
+++ b/service/relayconvert/text_converter_registry_test.go
@@ -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)
+ })
+ }
+}
diff --git a/service/request_converter.go b/service/request_converter.go
new file mode 100644
index 000000000000..3c912ffea9f9
--- /dev/null
+++ b/service/request_converter.go
@@ -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
+}
diff --git a/service/text_quota.go b/service/text_quota.go
index 42385ca8b95f..c99c75e78224 100644
--- a/service/text_quota.go
+++ b/service/text_quota.go
@@ -175,6 +175,9 @@ func composeTieredTextQuota(relayInfo *relaycommon.RelayInfo, summary textQuotaS
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 {
summary := textQuotaSummary{
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) {
originUsage := usage
+ billingUsage := effectiveBillingUsage(usage)
if usage == nil {
extraContent = append(extraContent, "上游无计费信息")
}
if originUsage != nil {
- ObserveChannelAffinityUsageCacheByRelayFormat(ctx, usage, relayInfo.GetFinalRequestRelayFormat())
+ ObserveChannelAffinityUsageCacheByRelayFormat(ctx, billingUsage, relayInfo.GetFinalRequestRelayFormat())
}
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
- summary := calculateTextQuotaSummary(ctx, relayInfo, usage)
+ summary := calculateTextQuotaSummary(ctx, relayInfo, billingUsage)
var tieredResult *billingexpr.TieredResult
tieredBillingApplied := false
@@ -352,7 +356,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
if snap := relayInfo.TieredBillingSnapshot; snap != nil {
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 {
tieredBillingApplied = true
tieredResult = tieredRes
@@ -412,6 +416,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
} else {
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 != "" {
other["reject_reason"] = adminRejectReason
}
@@ -462,12 +467,12 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
// to cache_creation_tokens.
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.
// 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
// 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 {
InjectTieredBillingInfo(other, relayInfo, tieredResult)
diff --git a/service/text_quota_test.go b/service/text_quota_test.go
index 3b2bcb91b887..717d35d514a9 100644
--- a/service/text_quota_test.go
+++ b/service/text_quota_test.go
@@ -150,6 +150,172 @@ func TestCalculateTextQuotaSummaryUsesAnthropicUsageSemanticFromUpstreamUsage(t
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) {
t.Run("split cache creation", func(t *testing.T) {
summary := textQuotaSummary{
diff --git a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx
index c287f05b6ed8..8dc4222dce39 100644
--- a/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx
+++ b/web/default/src/features/channels/components/dialogs/advanced-custom-editor-dialog.tsx
@@ -16,15 +16,35 @@ along with this program. If not, see
+ {t( + 'Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.' + )} +
+ {groupHasError && validationError ? ( ++ {validationError.routeIndex !== undefined + ? `${t('Route')} ${validationError.routeIndex + 1}: ` + : ''} + {t(validationError.message)} +
+ ) : null} +
{t(upstreamPathDescriptionKey)}
@@ -665,7 +1106,7 @@ function RouteEditor({
>
{t(errorMessage)}
+ {t(
+ 'Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.'
+ )}
+
+ {t(
+ 'Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.'
+ )}
+
+ {t(
+ 'Leave the final split empty as the fallback for models not matched above.'
+ )}
+