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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - NewAPI - - 40K - - Stars - - Thank you, builders - - - - - NewAPI - - - - - newapi.ai - - - - - - - - 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 ![image](data:image/jpeg;base64,xxxxxxxxxxxx) - // 使用字符串查找而非正则,避免大文本性能问题 - text := part.Text - hasMarkdownImage := false - for { - // 快速检查是否包含 markdown 图片标记 - 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, - }) - } - } - // 提取 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 生成一次中间 "![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("![image](data:") - content.WriteString(part.InlineData.MimeType) - content.WriteString(";base64,") - content.WriteString(part.InlineData.Data) - content.WriteByte(')') - } else { - // 其他媒体类型,直接显示链接 - writeSep() - content.WriteString("[media](data:") - content.WriteString(part.InlineData.MimeType) - content.WriteString(";base64,") - content.WriteString(part.InlineData.Data) - content.WriteByte(')') - } - } else if part.FunctionCall != nil { - choice.FinishReason = constant.FinishReasonToolCalls - if call := 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("![image](data:") - content.WriteString(part.InlineData.MimeType) - content.WriteString(";base64,") - content.WriteString(part.InlineData.Data) - content.WriteByte(')') - } - } else if part.FunctionCall != nil { - isTools = true - if call := 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("![image](data:") + content.WriteString(part.InlineData.MimeType) + content.WriteString(";base64,") + content.WriteString(part.InlineData.Data) + content.WriteByte(')') + } else { + writeSep() + content.WriteString("[media](data:") + content.WriteString(part.InlineData.MimeType) + content.WriteString(";base64,") + content.WriteString(part.InlineData.Data) + content.WriteByte(')') + } + } else if part.FunctionCall != nil { + choice.FinishReason = constant.FinishReasonToolCalls + if call := geminiResponseToolCall(&part); call != nil { + toolCalls = append(toolCalls, *call) + } + } else if part.Thought { + choice.Message.ReasoningContent = &part.Text + } else { + if part.ExecutableCode != nil { + writeSep() + content.WriteString("```") + content.WriteString(part.ExecutableCode.Language) + content.WriteByte('\n') + content.WriteString(part.ExecutableCode.Code) + content.WriteString("\n```") + } else if part.CodeExecutionResult != nil { + writeSep() + content.WriteString("```output\n") + content.WriteString(part.CodeExecutionResult.Output) + content.WriteString("\n```") + } else if part.Text != "\n" { + writeSep() + content.WriteString(part.Text) + } + } + } + if len(toolCalls) > 0 { + choice.Message.SetToolCalls(toolCalls) + isToolCall = true + } + choice.Message.SetStringContent(content.String()) + } + if candidate.FinishReason != nil { + switch *candidate.FinishReason { + case "STOP": + choice.FinishReason = constant.FinishReasonStop + case "MAX_TOKENS": + choice.FinishReason = constant.FinishReasonLength + case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER": + choice.FinishReason = constant.FinishReasonContentFilter + default: + choice.FinishReason = constant.FinishReasonContentFilter + } + } + if isToolCall { + choice.FinishReason = constant.FinishReasonToolCalls + } + + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) { + choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) + isStop := false + for _, candidate := range geminiResponse.Candidates { + if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" { + isStop = true + candidate.FinishReason = nil + } + choice := dto.ChatCompletionsStreamResponseChoice{ + Index: int(candidate.Index), + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{}, + } + var content strings.Builder + var inlineGrow int + for _, part := range candidate.Content.Parts { + if part.InlineData != nil { + inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32 + } + } + if inlineGrow > 0 { + content.Grow(inlineGrow) + } + appended := 0 + writeSep := func() { + if appended > 0 { + content.WriteByte('\n') + } + appended++ + } + isTools := false + isThought := false + if candidate.FinishReason != nil { + switch *candidate.FinishReason { + case "STOP": + choice.FinishReason = &constant.FinishReasonStop + case "MAX_TOKENS": + choice.FinishReason = &constant.FinishReasonLength + case "SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII", "OTHER": + choice.FinishReason = &constant.FinishReasonContentFilter + default: + choice.FinishReason = &constant.FinishReasonContentFilter + } + } + for _, part := range candidate.Content.Parts { + if part.InlineData != nil { + if strings.HasPrefix(part.InlineData.MimeType, "image") { + writeSep() + content.WriteString("![image](data:") + content.WriteString(part.InlineData.MimeType) + content.WriteString(";base64,") + content.WriteString(part.InlineData.Data) + content.WriteByte(')') + } + } else if part.FunctionCall != nil { + isTools = true + if call := geminiResponseToolCall(&part); call != nil { + call.SetIndex(len(choice.Delta.ToolCalls)) + choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call) + } + } else if part.Thought { + isThought = true + writeSep() + content.WriteString(part.Text) + } else { + if part.ExecutableCode != nil { + writeSep() + content.WriteString("```") + content.WriteString(part.ExecutableCode.Language) + content.WriteByte('\n') + content.WriteString(part.ExecutableCode.Code) + content.WriteString("\n```\n") + } else if part.CodeExecutionResult != nil { + writeSep() + content.WriteString("```output\n") + content.WriteString(part.CodeExecutionResult.Output) + content.WriteString("\n```\n") + } else if part.Text != "\n" { + writeSep() + content.WriteString(part.Text) + } + } + } + if isThought { + choice.Delta.SetReasoningContent(content.String()) + } else { + choice.Delta.SetContentString(content.String()) + } + if isTools { + choice.FinishReason = &constant.FinishReasonToolCalls + } + choices = append(choices, choice) + } + + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Choices: choices, + } + return &response, isStop +} + +func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse { + argsBytes, err := common.Marshal(item.FunctionCall.Arguments) + if err != nil { + return nil + } + return &dto.ToolCallResponse{ + ID: fmt.Sprintf("call_%s", common.GetUUID()), + Type: "function", + Function: dto.FunctionResponse{ + Arguments: string(argsBytes), + Name: item.FunctionCall.FunctionName, + }, + } +} 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 . For commercial licensing, please contact support@quantumnous.com */ -import { ArrowRight, Check, Plus, Shuffle, Trash2 } from 'lucide-react' +import { + ArrowDown, + ArrowDownToLine, + ArrowRight, + ArrowUp, + Check, + Info, + Plus, + Shuffle, + Trash2, + type LucideIcon, +} from 'lucide-react' import { type ReactNode, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Dialog } from '@/components/dialog' import { Alert, AlertDescription } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from '@/components/ui/popover' import { Select, SelectContent, @@ -53,13 +73,17 @@ import { createAdvancedCustomConfig, createAdvancedCustomRoute, getAdvancedCustomAuthMode, + getAdvancedCustomConverterDefaults, getAdvancedCustomConverterOptions, getAdvancedCustomIncomingPathLabel, + getAdvancedCustomModelRuleKind, + getAdvancedCustomRegexModelPattern, getAdvancedCustomTemplateConfig, getAdvancedCustomUpstreamPathPlaceholder, getDefaultAdvancedCustomIncomingPath, isAdvancedCustomIncomingPathAllowed, normalizeAdvancedCustomConfig, + parseAdvancedCustomRouteModels, parseAdvancedCustomConfig, stringifyAdvancedCustomConfig, validateAdvancedCustomConfig, @@ -84,9 +108,23 @@ const longSelectContentClass = 'w-[360px] max-w-[calc(100vw-2rem)]' const longSelectItemClass = 'items-start py-2 [&_[data-slot=select-item-text]]:min-w-0 [&_[data-slot=select-item-text]]:shrink [&_[data-slot=select-item-text]]:whitespace-normal' const routeEditorGridClassName = - 'lg:grid-cols-[7rem_minmax(0,1.45fr)_minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.85fr)_2rem]' + 'lg:grid-cols-[6rem_minmax(0,1fr)_minmax(0,1.25fr)_minmax(0,1fr)_minmax(0,0.85fr)_7rem]' const upstreamPathDescriptionKey = 'Use a path to append it to the channel Base URL, or enter a full URL to override the Base URL for this route.' +const catchAllOrderErrorMessage = + 'Catch-all route must be last for the same incoming path' +const emptyAdvancedRoutes: AdvancedCustomRoute[] = [] + +type AdvancedCustomRouteRow = { + route: AdvancedCustomRoute + routeKey: string + index: number +} + +type AdvancedCustomRouteGroup = { + incomingPath: string + routeRows: AdvancedCustomRouteRow[] +} function getOptionLabel( options: ReadonlyArray<{ value: string; label: string }>, @@ -95,6 +133,34 @@ function getOptionLabel( return options.find((option) => option.value === value)?.label || value } +function getRouteIncomingPath(route: AdvancedCustomRoute): string { + return (route.incoming_path || '').trim() +} + +function isCatchAllRoute(route: AdvancedCustomRoute): boolean { + return !route.models || route.models.length === 0 +} + +function buildRouteGroups( + routeRows: AdvancedCustomRouteRow[] +): AdvancedCustomRouteGroup[] { + const groups: AdvancedCustomRouteGroup[] = [] + const groupByPath = new Map() + + for (const routeRow of routeRows) { + const incomingPath = getRouteIncomingPath(routeRow.route) + let group = groupByPath.get(incomingPath) + if (!group) { + group = { incomingPath, routeRows: [] } + groupByPath.set(incomingPath, group) + groups.push(group) + } + group.routeRows.push(routeRow) + } + + return groups +} + export function AdvancedCustomEditorDialog({ open, value, @@ -133,20 +199,28 @@ export function AdvancedCustomEditorDialog({ () => normalizeAdvancedCustomConfig(config), [config] ) - const routes = normalizedConfig.advanced_routes || [] - const routeRows = routes.map((route, index) => ({ - route, - routeKey: - routeKeys.at(index) || - route.incoming_path || - route.upstream_path || - route.converter || - 'advanced-custom-route', - })) + const routes = normalizedConfig.advanced_routes || emptyAdvancedRoutes + const routeRows = useMemo( + () => + routes.map((route, index) => ({ + route, + index, + routeKey: + routeKeys.at(index) || + route.incoming_path || + route.upstream_path || + route.converter || + 'advanced-custom-route', + })), + [routeKeys, routes] + ) + const routeGroups = useMemo(() => buildRouteGroups(routeRows), [routeRows]) const validationError = useMemo( () => validateAdvancedCustomConfig(normalizedConfig), [normalizedConfig] ) + const canFixCatchAllOrder = + validationError?.message === catchAllOrderErrorMessage const createRouteKey = () => { routeKeyCounterRef.current += 1 @@ -165,6 +239,17 @@ export function AdvancedCustomEditorDialog({ }) } + const replaceRoutes = ( + nextRoutes: AdvancedCustomRoute[], + nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) + ) => { + setConfig((current) => { + const next = normalizeAdvancedCustomConfig(current) + return { ...next, advanced_routes: nextRoutes } + }) + setRouteKeys(nextRouteKeys) + } + const addRoute = () => { setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) @@ -179,6 +264,25 @@ export function AdvancedCustomEditorDialog({ setRouteKeys((current) => [...current, createRouteKey()]) } + const addRouteForIncomingPath = (incomingPath: string) => { + const resolvedIncomingPath = incomingPath || '/v1/chat/completions' + setConfig((current) => { + const next = normalizeAdvancedCustomConfig(current) + return { + ...next, + advanced_routes: [ + ...(next.advanced_routes || []), + { + ...createAdvancedCustomRoute(), + incoming_path: resolvedIncomingPath, + upstream_path: resolvedIncomingPath, + }, + ], + } + }) + setRouteKeys((current) => [...current, createRouteKey()]) + } + const removeRoute = (index: number) => { setConfig((current) => { const next = normalizeAdvancedCustomConfig(current) @@ -194,6 +298,105 @@ export function AdvancedCustomEditorDialog({ ) } + const updateGroupIncomingPath = ( + group: AdvancedCustomRouteGroup, + nextIncomingPath: string | null + ) => { + const resolvedIncomingPath = nextIncomingPath || '/v1/chat/completions' + const groupRouteIndexes = new Set( + group.routeRows.map((routeRow) => routeRow.index) + ) + const nextRoutes = routes.map((route, routeIndex) => { + if (!groupRouteIndexes.has(routeIndex)) return route + const converter = route.converter || 'none' + return { + ...route, + incoming_path: resolvedIncomingPath, + converter: isAdvancedCustomIncomingPathAllowed( + resolvedIncomingPath, + converter + ) + ? converter + : 'none', + } + }) + replaceRoutes(nextRoutes) + } + + const swapRoutes = (fromIndex: number, toIndex: number) => { + if (fromIndex === toIndex) return + const nextRoutes = [...routes] + const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) + const fromRoute = nextRoutes[fromIndex] + nextRoutes[fromIndex] = nextRoutes[toIndex] + nextRoutes[toIndex] = fromRoute + const fromRouteKey = nextRouteKeys[fromIndex] + nextRouteKeys[fromIndex] = nextRouteKeys[toIndex] + nextRouteKeys[toIndex] = fromRouteKey + replaceRoutes(nextRoutes, nextRouteKeys) + } + + const moveRouteWithinGroup = (index: number, direction: -1 | 1) => { + const incomingPath = getRouteIncomingPath(routes[index]) + const samePathIndexes = routes + .map((route, routeIndex) => ({ route, routeIndex })) + .filter(({ route }) => getRouteIncomingPath(route) === incomingPath) + .map(({ routeIndex }) => routeIndex) + const position = samePathIndexes.indexOf(index) + const nextIndex = samePathIndexes.at(position + direction) + if (nextIndex === undefined) return + swapRoutes(index, nextIndex) + } + + const moveRouteToGroupEnd = (index: number) => { + const incomingPath = getRouteIncomingPath(routes[index]) + let lastSamePathIndex = -1 + for (let routeIndex = routes.length - 1; routeIndex >= 0; routeIndex -= 1) { + if (getRouteIncomingPath(routes[routeIndex]) === incomingPath) { + lastSamePathIndex = routeIndex + break + } + } + if (lastSamePathIndex < 0 || index === lastSamePathIndex) return + + const nextRoutes = [...routes] + const nextRouteKeys = routeRows.map((routeRow) => routeRow.routeKey) + const [route] = nextRoutes.splice(index, 1) + const [routeKey] = nextRouteKeys.splice(index, 1) + nextRoutes.splice(lastSamePathIndex, 0, route) + nextRouteKeys.splice(lastSamePathIndex, 0, routeKey) + replaceRoutes(nextRoutes, nextRouteKeys) + } + + const fixCatchAllOrder = () => { + const routeRowsByPath = new Map() + for (const routeRow of routeRows) { + const incomingPath = getRouteIncomingPath(routeRow.route) + routeRowsByPath.set(incomingPath, [ + ...(routeRowsByPath.get(incomingPath) || []), + routeRow, + ]) + } + + const orderedRowsByPath = new Map() + for (const [incomingPath, rows] of routeRowsByPath) { + orderedRowsByPath.set(incomingPath, [ + ...rows.filter((routeRow) => !isCatchAllRoute(routeRow.route)), + ...rows.filter((routeRow) => isCatchAllRoute(routeRow.route)), + ]) + } + + const nextRows = routeRows.map((routeRow) => { + const incomingPath = getRouteIncomingPath(routeRow.route) + const orderedRows = orderedRowsByPath.get(incomingPath) + return orderedRows?.shift() || routeRow + }) + replaceRoutes( + nextRows.map((routeRow) => routeRow.route), + nextRows.map((routeRow) => routeRow.routeKey) + ) + } + const parseJsonEditorConfig = (): AdvancedCustomConfig | null => { const parsed = parseAdvancedCustomConfig(jsonText) if (!parsed) { @@ -302,7 +505,7 @@ export function AdvancedCustomEditorDialog({ {t('Cancel')} @@ -395,18 +598,30 @@ export function AdvancedCustomEditorDialog({ size='sm' onClick={addRoute} > - + {t('Add route')} {validationError ? ( - - {validationError.routeIndex !== undefined - ? `${t('Route')} ${validationError.routeIndex + 1}: ` - : ''} - {t(validationError.message)} + + + {validationError.routeIndex !== undefined + ? `${t('Route')} ${validationError.routeIndex + 1}: ` + : ''} + {t(validationError.message)} + + {canFixCatchAllOrder ? ( + + ) : null} ) : null} @@ -415,27 +630,24 @@ export function AdvancedCustomEditorDialog({ {t(upstreamPathDescriptionKey)}

-
- - {routeRows.map((routeRow, index) => ( - updateRoute(index, patch)} - onRemove={() => removeRoute(index)} +
+ {routeGroups.map((routeGroup) => ( + + addRouteForIncomingPath(routeGroup.incomingPath) + } + onIncomingPathChange={(nextIncomingPath) => + updateGroupIncomingPath(routeGroup, nextIncomingPath) + } + onMoveRoute={(index, direction) => + moveRouteWithinGroup(index, direction) + } + onMoveRouteToEnd={moveRouteToGroupEnd} + onRemoveRoute={removeRoute} + onRouteChange={updateRoute} /> ))}
@@ -476,15 +688,191 @@ export function AdvancedCustomEditorDialog({ ) } +function RouteGroupEditor({ + group, + validationError, + onAddRoute, + onIncomingPathChange, + onMoveRoute, + onMoveRouteToEnd, + onRemoveRoute, + onRouteChange, +}: { + group: AdvancedCustomRouteGroup + validationError: ReturnType + onAddRoute: () => void + onIncomingPathChange: (incomingPath: string | null) => void + onMoveRoute: (index: number, direction: -1 | 1) => void + onMoveRouteToEnd: (index: number) => void + onRemoveRoute: (index: number) => void + onRouteChange: (index: number, patch: Partial) => void +}) { + const { t } = useTranslation() + const incomingPath = group.incomingPath || '/v1/chat/completions' + const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath) + const catchAllRoute = group.routeRows.find((routeRow) => + isCatchAllRoute(routeRow.route) + ) + const catchAllRoutePosition = catchAllRoute + ? group.routeRows.findIndex( + (routeRow) => routeRow.index === catchAllRoute.index + ) + : -1 + const hasCatchAll = catchAllRoute !== undefined + const catchAllIsLast = + !hasCatchAll || catchAllRoutePosition === group.routeRows.length - 1 + const groupHasError = + validationError?.routeIndex !== undefined && + group.routeRows.some( + (routeRow) => routeRow.index === validationError.routeIndex + ) + + return ( +
+
+
+
+ {t('Route group')} + + {group.routeRows.length} {t('Routes')} + + + {hasCatchAll ? t('Fallback route') : t('Model-scoped only')} + + {!catchAllIsLast ? ( + {t('Fallback must be last')} + ) : null} +
+ +
+ + +
+ +
+

+ {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} +
+ + + +
+ {group.routeRows.map((routeRow, position) => { + const canMoveUp = position > 0 + const canMoveDown = position < group.routeRows.length - 1 + const catchAllOutOfOrder = + isCatchAllRoute(routeRow.route) && canMoveDown + const routeErrorMessage = + validationError?.routeIndex === routeRow.index + ? validationError.message + : undefined + + return ( + onRouteChange(routeRow.index, patch)} + onMoveDown={() => onMoveRoute(routeRow.index, 1)} + onMoveUp={() => onMoveRoute(routeRow.index, -1)} + onMoveCatchAllToEnd={() => onMoveRouteToEnd(routeRow.index)} + onRemove={() => onRemoveRoute(routeRow.index)} + /> + ) + })} +
+
+ ) +} + function RouteEditor({ route, index, + errorMessage, + canMoveUp, + canMoveDown, + catchAllOutOfOrder, onChange, + onMoveUp, + onMoveDown, + onMoveCatchAllToEnd, onRemove, }: { route: AdvancedCustomRoute index: number + errorMessage?: string + canMoveUp: boolean + canMoveDown: boolean + catchAllOutOfOrder: boolean onChange: (patch: Partial) => void + onMoveUp: () => void + onMoveDown: () => void + onMoveCatchAllToEnd: () => void onRemove: () => void }) { const { t } = useTranslation() @@ -496,39 +884,52 @@ function RouteEditor({ () => getAdvancedCustomConverterOptions(incomingPath), [incomingPath] ) - const incomingPathLabel = getAdvancedCustomIncomingPathLabel(incomingPath) const converterLabel = getOptionLabel( ADVANCED_CUSTOM_CONVERTER_OPTIONS, converter ) + const converterTriggerLabel = + ADVANCED_CUSTOM_CONVERTER_OPTIONS.find( + (option) => option.value === converter + )?.triggerLabel || converterLabel const authLabel = getOptionLabel(ADVANCED_CUSTOM_AUTH_MODE_OPTIONS, authMode) const isNativeConverter = converter === 'none' const ConverterVisualIcon = isNativeConverter ? ArrowRight : Shuffle + const modelsInputValue = route.models?.join(', ') || '' + const parsedRouteModels = parseAdvancedCustomRouteModels(modelsInputValue) + const isFallback = parsedRouteModels.length === 0 const setConverter = (nextConverter: AdvancedCustomConverter) => { - const patch: Partial = { converter: nextConverter } - if (!isAdvancedCustomIncomingPathAllowed(incomingPath, nextConverter)) { - patch.incoming_path = getDefaultAdvancedCustomIncomingPath(nextConverter) - } - onChange(patch) - } - - const setIncomingPath = (nextIncomingPath: string | null) => { - const resolvedIncomingPath = - nextIncomingPath || getDefaultAdvancedCustomIncomingPath(converter) - const patch: Partial = { - incoming_path: resolvedIncomingPath, + let nextIncomingPath = incomingPath + if (!isAdvancedCustomIncomingPathAllowed(nextIncomingPath, nextConverter)) { + nextIncomingPath = getDefaultAdvancedCustomIncomingPath(nextConverter) } - if (!isAdvancedCustomIncomingPathAllowed(resolvedIncomingPath, converter)) { - patch.converter = 'none' - } - onChange(patch) + const defaults = getAdvancedCustomConverterDefaults( + nextConverter, + nextIncomingPath + ) + onChange({ + converter: nextConverter, + incoming_path: nextIncomingPath, + upstream_path: defaults.upstream_path, + auth: defaults.auth, + }) } const setAuthMode = (mode: AdvancedCustomAuthMode) => { onChange({ auth: buildAdvancedCustomAuth(mode, route.auth) }) } + const setModelsInput = (value: string) => { + onChange({ + models: value === '' ? [] : value.split(','), + }) + } + + const normalizeModelsInput = (value: string) => { + onChange({ models: parseAdvancedCustomRouteModels(value) }) + } + const updateAuth = ( field: Exclude, 'type'>, value: string @@ -546,7 +947,12 @@ function RouteEditor({ } return ( -
+
{t('Route')} {index + 1}
+ {isFallback ? ( + {t('Fallback')} + ) : null}
- +
+ + + {catchAllOutOfOrder ? ( + + ) : null} + +
+ {t('Client model')} + + + } className='lg:gap-1' labelClassName='lg:sr-only' > - + + {t(ruleKind === 'regex' ? 'Regex' : 'Exact')} + + {displayModel} + + ) + }) + )} +

{t(upstreamPathDescriptionKey)} @@ -665,7 +1106,7 @@ function RouteEditor({ > - {t(converterLabel)} + {t(converterTriggerLabel)} - +

+ + + {catchAllOutOfOrder ? ( + + ) : null} + +
+ {errorMessage ? ( +

{t(errorMessage)}

+ ) : null} + {authMode === 'header' || authMode === 'query' ? ( <> @@ -775,13 +1236,101 @@ function RouteEditor({ ) } +function ModelRuleHelpPopover() { + const { t } = useTranslation() + + return ( + + + } + > + + + + {t('Client model matching')} + + {t( + 'Rules match the original model value from the client request body.' + )} + + +
+

+ {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.' + )} +

+
+
+
+ ) +} + +function TooltipIconButton({ + label, + icon: Icon, + disabled, + onClick, +}: { + label: string + icon: LucideIcon + disabled?: boolean + onClick: () => void +}) { + return ( + + + + } + > + + {label} + + + ) +} + function FieldBlock({ label, className, labelClassName, children, }: { - label: string + label: ReactNode className?: string labelClassName?: string children: ReactNode diff --git a/web/default/src/features/channels/lib/advanced-custom.ts b/web/default/src/features/channels/lib/advanced-custom.ts index 0e2f6cf255e0..62216160af59 100644 --- a/web/default/src/features/channels/lib/advanced-custom.ts +++ b/web/default/src/features/channels/lib/advanced-custom.ts @@ -29,31 +29,47 @@ export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58 export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{ value: AdvancedCustomConverter label: string + triggerLabel: string }> = [ - { value: 'none', label: 'Native forwarding' }, + { + value: 'none', + label: 'Native forwarding', + triggerLabel: 'Native forwarding', + }, { value: 'anthropic_messages_to_openai_chat_completions', label: 'Anthropic Messages to OpenAI Chat', + triggerLabel: 'To OpenAI Chat', }, { value: 'openai_chat_completions_to_anthropic_messages', label: 'OpenAI Chat to Anthropic Messages', + triggerLabel: 'To Anthropic Messages', }, { value: 'openai_chat_completions_to_openai_responses', label: 'OpenAI Chat to OpenAI Responses', + triggerLabel: 'To OpenAI Responses', }, { value: 'openai_responses_to_openai_chat_completions', label: 'OpenAI Responses to OpenAI Chat', + triggerLabel: 'To OpenAI Chat', + }, + { + value: 'openai_responses_to_gemini_generate_content', + label: 'OpenAI Responses to Gemini Generate Content', + triggerLabel: 'To Gemini Generate Content', }, { value: 'gemini_generate_content_to_openai_chat_completions', label: 'Gemini Generate Content to OpenAI Chat', + triggerLabel: 'To OpenAI Chat', }, { value: 'openai_chat_completions_to_gemini_generate_content', label: 'OpenAI Chat to Gemini Generate Content', + triggerLabel: 'To Gemini Generate Content', }, ] @@ -157,6 +173,20 @@ export type AdvancedCustomTemplateOption = { config: AdvancedCustomConfig } +export type AdvancedCustomConverterDefaults = { + upstream_path: string + auth?: AdvancedCustomRouteAuth +} + +export const ADVANCED_CUSTOM_MODEL_REGEX_PREFIX = 're:' + +export type AdvancedCustomModelRuleKind = 'exact' | 'regex' + +const openAIChatPath = '/v1/chat/completions' +const openAIResponsesPath = '/v1/responses' +const claudeMessagesPath = '/v1/messages' +const geminiGenerateContentPath = '/v1beta/models/{model}:generateContent' + const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({ type: 'header', name: 'Authorization', @@ -313,8 +343,8 @@ export function getAdvancedCustomTemplateConfig( export function createAdvancedCustomRoute(): AdvancedCustomRoute { return { - incoming_path: '/v1/chat/completions', - upstream_path: '/v1/chat/completions', + incoming_path: openAIChatPath, + upstream_path: openAIChatPath, converter: 'none', } } @@ -326,18 +356,67 @@ export function createAdvancedCustomConfig(): AdvancedCustomConfig { } export function getAdvancedCustomUpstreamPathPlaceholder( - converter: AdvancedCustomConverter + converter: AdvancedCustomConverter, + incomingPath = getDefaultAdvancedCustomIncomingPath(converter) ): string { - if (converter === 'openai_chat_completions_to_gemini_generate_content') { - return '/v1beta/models/{model}:generateContent' + return getAdvancedCustomConverterDefaults(converter, incomingPath) + .upstream_path +} + +export function getAdvancedCustomConverterDefaults( + converter: AdvancedCustomConverter, + incomingPath: string +): AdvancedCustomConverterDefaults { + const normalizedIncomingPath = + incomingPath.trim() || getDefaultAdvancedCustomIncomingPath(converter) + + if (converter === 'none') { + return { + upstream_path: normalizedIncomingPath, + auth: getAdvancedCustomNativeAuth(normalizedIncomingPath), + } + } + if ( + converter === 'anthropic_messages_to_openai_chat_completions' || + converter === 'gemini_generate_content_to_openai_chat_completions' || + converter === 'openai_responses_to_openai_chat_completions' + ) { + return { upstream_path: openAIChatPath, auth: bearerHeaderAuth() } + } + if (converter === 'openai_chat_completions_to_openai_responses') { + return { upstream_path: openAIResponsesPath, auth: bearerHeaderAuth() } } if (converter === 'openai_chat_completions_to_anthropic_messages') { - return '/v1/messages' + return { upstream_path: claudeMessagesPath, auth: apiKeyHeaderAuth() } } - if (converter === 'openai_responses_to_openai_chat_completions') { - return '/v1/chat/completions' + if ( + converter === 'openai_chat_completions_to_gemini_generate_content' || + converter === 'openai_responses_to_gemini_generate_content' + ) { + return { upstream_path: geminiGenerateContentPath, auth: geminiQueryAuth() } + } + + return { + upstream_path: normalizedIncomingPath || openAIChatPath, + auth: getAdvancedCustomNativeAuth(normalizedIncomingPath), } - return '/v1/chat/completions' +} + +function getAdvancedCustomNativeAuth( + incomingPath: string +): AdvancedCustomRouteAuth { + if (incomingPath === claudeMessagesPath) { + return apiKeyHeaderAuth() + } + if ( + incomingPath.includes(':generateContent') || + incomingPath.includes(':streamGenerateContent') || + incomingPath.includes(':embedContent') || + incomingPath.includes(':batchEmbedContents') + ) { + return geminiQueryAuth() + } + return bearerHeaderAuth() } export function getAdvancedCustomIncomingPathOptions( @@ -416,6 +495,29 @@ export function normalizeAdvancedCustomConfig( } } +export function parseAdvancedCustomRouteModels(value: string): string[] { + return [ + ...new Set( + value + .split(',') + .map((model) => model.trim()) + .filter(Boolean) + ), + ] +} + +export function getAdvancedCustomModelRuleKind( + modelRule: string +): AdvancedCustomModelRuleKind { + return modelRule.startsWith(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX) + ? 'regex' + : 'exact' +} + +export function getAdvancedCustomRegexModelPattern(modelRule: string): string { + return modelRule.slice(ADVANCED_CUSTOM_MODEL_REGEX_PREFIX.length) +} + export function validateAdvancedCustomConfig( config: AdvancedCustomConfig | null ): AdvancedCustomValidationError | null { @@ -431,12 +533,16 @@ export function validateAdvancedCustomConfig( } } - const seenPaths = new Set() + const routeModelsByPath = new Map< + string, + { catchAllIndex: number | null; models: Map } + >() for (let index = 0; index < routes.length; index += 1) { const route = routes[index] const incomingPath = route.incoming_path?.trim() || '' const upstreamPath = getAdvancedCustomRouteUpstreamPath(route) const converter = route.converter || 'none' + const routeModels = normalizeAdvancedCustomRouteModels(route.models) if (!incomingPath) { return { routeIndex: index, message: 'Incoming path is required' } @@ -450,10 +556,15 @@ export function validateAdvancedCustomConfig( message: 'Incoming path must not include query', } } - if (seenPaths.has(incomingPath)) { - return { routeIndex: index, message: 'Incoming path must be unique' } + const routeModelsError = validateAdvancedCustomRouteModels( + index, + incomingPath, + routeModels, + routeModelsByPath + ) + if (routeModelsError) { + return routeModelsError } - seenPaths.add(incomingPath) if (!upstreamPath) { return { routeIndex: index, message: 'Upstream path is required' } @@ -555,6 +666,10 @@ function normalizeAdvancedCustomRoute( upstream_path: getAdvancedCustomRouteUpstreamPath(route), converter: route.converter || 'none', } + const models = normalizeAdvancedCustomRouteModels(route.models) + if (models.length > 0) { + nextRoute.models = models + } if (route.auth) { nextRoute.auth = { type: route.auth.type, @@ -565,6 +680,70 @@ function normalizeAdvancedCustomRoute( return nextRoute } +function normalizeAdvancedCustomRouteModels( + models: string[] | undefined +): string[] { + if (!Array.isArray(models)) return [] + return models.map((model) => model.trim()).filter(Boolean) +} + +function validateAdvancedCustomRouteModels( + routeIndex: number, + incomingPath: string, + models: string[], + routeModelsByPath: Map< + string, + { catchAllIndex: number | null; models: Map } + > +): AdvancedCustomValidationError | null { + let state = routeModelsByPath.get(incomingPath) + if (!state) { + state = { catchAllIndex: null, models: new Map() } + routeModelsByPath.set(incomingPath, state) + } + + if (models.length === 0) { + if (state.catchAllIndex !== null) { + return { + routeIndex, + message: + 'Only one catch-all route is allowed for the same incoming path', + } + } + state.catchAllIndex = routeIndex + return null + } + + if (state.catchAllIndex !== null) { + return { + routeIndex, + message: 'Catch-all route must be last for the same incoming path', + } + } + + const seenInRoute = new Set() + for (const model of models) { + if ( + getAdvancedCustomModelRuleKind(model) === 'regex' && + getAdvancedCustomRegexModelPattern(model) === '' + ) { + return { routeIndex, message: 'Model regex cannot be empty' } + } + if (seenInRoute.has(model)) { + return { routeIndex, message: 'Duplicate model in route models' } + } + seenInRoute.add(model) + if (state.models.has(model)) { + return { + routeIndex, + message: 'Route models must be unique for the same incoming path', + } + } + state.models.set(model, routeIndex) + } + return null +} + function getAdvancedCustomRouteUpstreamPath( route: AdvancedCustomRoute ): string { @@ -622,6 +801,9 @@ function isConverterPathAllowed( if (converter === 'openai_responses_to_openai_chat_completions') { return incomingPath === '/v1/responses' } + if (converter === 'openai_responses_to_gemini_generate_content') { + return incomingPath === '/v1/responses' + } return ( incomingPath.includes(':generateContent') || incomingPath.includes(':streamGenerateContent') diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index 6d9b7b1d660b..96bd5b2cfd12 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -117,6 +117,7 @@ export interface AdvancedCustomRoute { incoming_path?: string upstream_path?: string converter?: AdvancedCustomConverter + models?: string[] auth?: AdvancedCustomRouteAuth } @@ -132,6 +133,7 @@ export type AdvancedCustomConverter = | 'openai_chat_completions_to_anthropic_messages' | 'openai_chat_completions_to_openai_responses' | 'openai_responses_to_openai_chat_completions' + | 'openai_responses_to_gemini_generate_content' | 'gemini_generate_content_to_openai_chat_completions' | 'openai_chat_completions_to_gemini_generate_content' diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx index d38465b3bb36..7ed39d02c1a0 100644 --- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx +++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx @@ -31,6 +31,7 @@ import { Info, LogIn, } from 'lucide-react' +import type { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' import { Dialog } from '@/components/dialog' @@ -62,7 +63,7 @@ import { isPerCallBilling, isTimingLogType, } from '../../lib/utils' -import type { LogOtherData } from '../../types' +import { USAGE_BILLING_PATH, type LogOtherData } from '../../types' // Maps a channel-update changed-field token (as recorded by the backend audit) // to its i18n label key for display in the audit details. @@ -150,6 +151,41 @@ function formatRatio(ratio: number | undefined): string { return ratio.toFixed(4) } +function getUsageBillingPathLabel( + t: TFunction, + adminInfo: LogOtherData['admin_info'] +): string { + switch (adminInfo?.usage_billing_path) { + case USAGE_BILLING_PATH.LOCAL: + return t('Local Billing') + case USAGE_BILLING_PATH.OPENAI: + return t('Upstream Response (billing-usage-openai)') + case USAGE_BILLING_PATH.OPENAI_ESTIMATED: + return t('Upstream Response (billing-usage-openai-estimated)') + case USAGE_BILLING_PATH.ANTHROPIC: + return t('Upstream Response (billing-usage-anthropic)') + case USAGE_BILLING_PATH.ANTHROPIC_ESTIMATED: + return t('Upstream Response (billing-usage-anthropic-estimated)') + case USAGE_BILLING_PATH.GEMINI: + return t('Upstream Response (billing-usage-gemini)') + case USAGE_BILLING_PATH.GEMINI_ESTIMATED: + return t('Upstream Response (billing-usage-gemini-estimated)') + case USAGE_BILLING_PATH.UPSTREAM: + return t('Upstream Response') + default: + return adminInfo?.local_count_tokens + ? t('Local Billing') + : t('Upstream Response') + } +} + +function isUsageBillingPathLocal(adminInfo: LogOtherData['admin_info']): boolean { + if (adminInfo?.usage_billing_path) { + return adminInfo.usage_billing_path === USAGE_BILLING_PATH.LOCAL + } + return adminInfo?.local_count_tokens === true +} + function quotaSaturationKindLabel( kind: 'overflow' | 'underflow' | 'nan', t: (key: string) => string @@ -326,10 +362,8 @@ function BillingBreakdown(props: { if (isAdmin && other.admin_info) { rows.push({ - label: t('Billing Source'), - value: other.admin_info.local_count_tokens - ? t('Local Billing') - : t('Upstream Response'), + label: t('Billing Path'), + value: getUsageBillingPathLabel(t, other.admin_info), }) } @@ -1037,18 +1071,16 @@ export function DetailsDialog(props: DetailsDialogProps) { props.log.type !== 6 && other?.admin_info && ( - {other.admin_info.local_count_tokens ? ( + {isUsageBillingPathLocal(other.admin_info) ? ( ) : ( )} - {other.admin_info.local_count_tokens - ? t('Local Billing') - : t('Upstream Response')} + {getUsageBillingPathLabel(t, other.admin_info)} } diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts index 0393e3aa5178..a03d393546a3 100644 --- a/web/default/src/features/usage-logs/types.ts +++ b/web/default/src/features/usage-logs/types.ts @@ -92,12 +92,27 @@ export interface ChannelAffinityInfo { using_group?: string } +export const USAGE_BILLING_PATH = { + LOCAL: 'local', + UPSTREAM: 'upstream', + OPENAI: 'billing-usage-openai', + OPENAI_ESTIMATED: 'billing-usage-openai-estimated', + ANTHROPIC: 'billing-usage-anthropic', + ANTHROPIC_ESTIMATED: 'billing-usage-anthropic-estimated', + GEMINI: 'billing-usage-gemini', + GEMINI_ESTIMATED: 'billing-usage-gemini-estimated', +} as const + +export type UsageBillingPath = + (typeof USAGE_BILLING_PATH)[keyof typeof USAGE_BILLING_PATH] + export interface LogOtherData { admin_info?: { is_multi_key?: boolean multi_key_index?: number use_channel?: number[] local_count_tokens?: boolean + usage_billing_path?: UsageBillingPath | string channel_affinity?: ChannelAffinityInfo // Top-up audit fields (type=1, admin only) payment_method?: string diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index ee31249093a6..2d0687f7416d 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -207,6 +207,7 @@ "Add rule group": "Add rule group", "Add rules for a user group": "Add rules for a user group", "Add selectable group": "Add selectable group", + "Add split": "Add split", "Add subscription": "Add subscription", "Add tags...": "Add tags...", "Add tier": "Add tier", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "Billing group = vip (the token has no group, so use the user group)", "Billing History": "Billing History", "Billing Mode": "Billing Mode", + "Billing Path": "Billing Path", "Billing Process": "Billing Process", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.", "Billing Source": "Billing Source", @@ -715,6 +717,7 @@ "Caps the response length": "Caps the response length", "Capture a reusable bundle of models, tags, or endpoints.": "Capture a reusable bundle of models, tags, or endpoints.", "Card view": "Card view", + "Catch-all route must be last for the same incoming path": "Catch-all route must be last for the same incoming path", "Category": "Category", "Category Name": "Category Name", "Category name is required": "Category name is required", @@ -867,6 +870,9 @@ "Click to view image": "Click to view image", "Client header value": "Client header value", "Client ID": "Client ID", + "Client model": "Client model", + "Client model matching": "Client model matching", + "Client model matching help": "Client model matching help", "Client Secret": "Client Secret", "Close": "Close", "Close dialog": "Close dialog", @@ -1446,6 +1452,7 @@ "Drawing task records": "Drawing task records", "Duplicate": "Duplicate", "Duplicate group names: {{names}}": "Duplicate group names: {{names}}", + "Duplicate model in route models": "Duplicate model in route models", "Duplicate source model mappings are not allowed": "Duplicate source model mappings are not allowed", "Duplicate source model(s): {{models}}": "Duplicate source model(s): {{models}}", "Duration": "Duration", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "e.g. Basic Plan", "e.g. Clean tool parameters to avoid upstream validation errors": "e.g. Clean tool parameters to avoid upstream validation errors", "e.g. example.com": "e.g. example.com", + "e.g. gpt-4o, gemini-2.5-flash": "e.g. gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "e.g. llama3.1:8b", "e.g. My GitLab": "e.g. My GitLab", "e.g. my-gitlab": "e.g. my-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "Everything configured for this group, in one place.", "Exact": "Exact", "Exact Match": "Exact Match", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.", "Example": "Example", "Example (all channels):": "Example (all channels):", "Example (specific channels):": "Example (specific channels):", @@ -1903,7 +1912,11 @@ "Failed to update user": "Failed to update user", "Failure keywords": "Failure keywords", "Fair": "Fair", + "Fallback": "Fallback", "Fallback base URL": "Fallback base URL", + "Fallback for remaining models": "Fallback for remaining models", + "Fallback must be last": "Fallback must be last", + "Fallback route": "Fallback route", "Fallback tier": "Fallback tier", "FAQ": "FAQ", "FAQ added. Click \"Save Settings\" to apply.": "FAQ added. Click \"Save Settings\" to apply.", @@ -1941,6 +1954,7 @@ "Fill Related Models": "Fill Related Models", "Fill Template": "Fill Template", "Fill Templates": "Fill Templates", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format", "Filled {{count}} model(s)": "Filled {{count}} model(s)", "Filled {{count}} related model(s)": "Filled {{count}} related model(s)", @@ -1982,6 +1996,7 @@ "First token": "First token", "First/Last Frame to Video": "First/Last Frame to Video", "Fix Abilities": "Repair Channel Consistency", + "Fix order": "Fix order", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed", "Fixed price": "Fixed price", "Fixed price (USD)": "Fixed price (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "Leave blank to keep the existing credential", "Leave blank to keep the existing key": "Leave blank to keep the existing key", "Leave blank unless rotating the secret": "Leave blank unless rotating the secret", + "Leave empty for fallback": "Leave empty for fallback", "Leave empty for never expires": "Leave empty for never expires", + "Leave empty only for the final fallback split.": "Leave empty only for the final fallback split.", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.", "Leave empty to disband the tag": "Leave empty to disband the tag", "Leave empty to keep existing key": "Leave empty to keep existing key", "Leave empty to keep unchanged": "Leave empty to keep unchanged", + "Leave empty to match all models": "Leave empty to match all models", "Leave empty to use account email": "Leave empty to use account email", "Leave empty to use default": "Leave empty to use default", "Leave empty to use system temp directory": "Leave empty to use system temp directory", "Leave empty to use username": "Leave empty to use username", + "Leave the final split empty as the fallback for models not matched above.": "Leave the final split empty as the fallback for models not matched above.", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "Leave this empty only for the final fallback split; it catches client models not matched above.", "Left to Right": "Left to Right", "Legacy Format (JSON Object)": "Legacy Format (JSON Object)", "Legacy format must be a JSON object": "Legacy format must be a JSON object", @@ -2482,6 +2502,9 @@ "Loading...": "Loading...", "Local": "Local", "Local Billing": "Local Billing", + "Local Estimate (billing-usage-anthropic)": "Local Estimate (billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "Local Estimate (billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "Local Estimate (billing-usage-openai)", "Local models": "Local models", "Locations": "Locations", "Locked": "Locked", @@ -2548,7 +2571,9 @@ "Match Value": "Match Value", "Match Value (optional)": "Match Value (optional)", "Matched": "Matched", + "Matched models": "Matched models", "Matched Tier": "Matched Tier", + "Matches models not claimed by earlier splits.": "Matches models not claimed by earlier splits.", "Matching Rules": "Matching Rules", "Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)", "Max Entries": "Max Entries", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "Model ratios reset successfully", "Model Regex": "Model Regex", "Model Regex (one per line)": "Model Regex (one per line)", + "Model regex cannot be empty": "Model regex cannot be empty", + "Model scope": "Model scope", "Model selected": "Model selected", "Model Square": "Model Square", "Model Tags": "Model Tags", "Model to use for testing": "Model to use for testing", "Model to use when testing channel connectivity": "Model to use when testing channel connectivity", "Model Version *": "Model Version *", + "Model-scoped only": "Model-scoped only", "model(s) selected out of": "model(s) selected out of", "model(s)? This action cannot be undone.": "model(s)? This action cannot be undone.", "models": "models", @@ -2714,9 +2742,12 @@ "Move": "Move", "Move a request header": "Move a request header", "Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance", + "Move fallback to end": "Move fallback to end", "Move Field": "Move Field", "Move Header": "Move Header", "Move Request Header": "Move Request Header", + "Move route down": "Move route down", + "Move route up": "Move route up", "Move source field to target field": "Move source field to target field", "ms": "ms", "Multi-key channel: Keys will be": "Multi-key channel: Keys will be", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.", "Only Mine": "Only Mine", + "Only one catch-all route is allowed for the same incoming path": "Only one catch-all route is allowed for the same incoming path", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.", "Only successful requests": "Only successful requests", "Only successful requests count toward this limit.": "Only successful requests count toward this limit.", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses to Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses to OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.", @@ -3835,9 +3868,15 @@ "Route": "Route", "Route active": "Route active", "Route Description": "Route Description", + "Route group": "Route group", "Route is required": "Route is required", + "Route models must be unique for the same incoming path": "Route models must be unique for the same incoming path", "Route, auth, and balance check in one place": "Route, auth, and balance check in one place", "Routes": "Routes", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.", "Routing & Overrides": "Routing & Overrides", "Routing Reliability": "Routing Reliability", "Routing Strategy": "Routing Strategy", @@ -3862,6 +3901,7 @@ "Rules": "Rules", "Rules JSON": "Rules JSON", "Rules JSON must be an array": "Rules JSON must be an array", + "Rules match the original model value from the client request body.": "Rules match the original model value from the client request body.", "Run GC": "Run GC", "Run tests for the selected models": "Run tests for the selected models", "running": "running", @@ -4057,6 +4097,7 @@ "Sensitive Words": "Sensitive Words", "Sent the API key to FluentRead.": "Sent the API key to FluentRead.", "Separate image/audio prices are enabled.": "Separate image/audio prices are enabled.", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.", "Serve multiple users or teams with billing and quota control.": "Serve multiple users or teams with billing and quota control.", "Server Address": "Server Address", "Server IP": "Server IP", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "This FAQ entry will be removed from the list.", "This feature is experimental. Configuration format and behavior may change.": "This feature is experimental. Configuration format and behavior may change.", "This feature requires server-side WeChat configuration": "This feature requires server-side WeChat configuration", + "This field does not support wildcards or regular expressions.": "This field does not support wildcards or regular expressions.", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.", @@ -4536,9 +4578,13 @@ "Timing": "Timing", "Tip": "Tip", "to access this resource.": "to access this resource.", + "To Anthropic Messages": "To Anthropic Messages", "to confirm": "to confirm", + "To Gemini Generate Content": "To Gemini Generate Content", "To Lower": "To Lower", "To Lowercase": "To Lowercase", + "To OpenAI Chat": "To OpenAI Chat", + "To OpenAI Responses": "To OpenAI Responses", "to override billing when a user in one group uses a token of another group.": "to override billing when a user in one group uses a token of another group.", "to the Models list so users can use them before the mapping sends traffic upstream.": "to the Models list so users can use them before the mapping sends traffic upstream.", "To Upper": "To Upper", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "Upstream ratios fetched successfully", "Upstream Request ID": "Upstream Request ID", "Upstream Response": "Upstream Response", + "Upstream Response (billing-usage-anthropic-estimated)": "Upstream Response (billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "Upstream Response (billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "Upstream Response (billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "Upstream Response (billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "Upstream Response (billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "Upstream Response (billing-usage-openai)", "upstream services integrated": "upstream services integrated", "Upstream Updates": "Upstream Updates", "Upstream URL": "Upstream URL", @@ -4819,6 +4871,8 @@ "Use authenticator code": "Use authenticator code", "Use backup code": "Use backup code", "Use disk cache when request body exceeds this size": "Use disk cache when request body exceeds this size", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.", "Use external tools to extend capabilities": "Use external tools to extend capabilities", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Use one available reset credit for this channel. The reset request is sent only after confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Use one available reset credit to refresh the current Codex usage windows.", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 20c7b54b5191..dbb6b7c1d243 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -207,6 +207,7 @@ "Add rule group": "Ajouter un groupe de règles", "Add rules for a user group": "Ajouter des règles pour un groupe d’utilisateurs", "Add selectable group": "Ajouter un groupe sélectionnable", + "Add split": "Ajouter une branche", "Add subscription": "Ajouter un abonnement", "Add tags...": "Ajouter des étiquettes...", "Add tier": "Ajouter un palier", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "Groupe de facturation = vip (le jeton n’a pas de groupe, on utilise le groupe de l’utilisateur)", "Billing History": "Historique de facturation", "Billing Mode": "Mode de facturation", + "Billing Path": "Chemin de facturation", "Billing Process": "Processus de facturation", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Règle de facturation : chaque appel est facturé selon le groupe du jeton (à défaut, le groupe de l’utilisateur). Le taux de base provient toujours de ce groupe de facturation, pas du groupe de l’utilisateur. Pour accorder à un groupe d’utilisateurs un tarif spécial sur un autre groupe de facturation, ajoutez une entrée dans la matrice de remplacement.", "Billing Source": "Source de facturation", @@ -715,6 +717,7 @@ "Caps the response length": "Limite la longueur de la réponse", "Capture a reusable bundle of models, tags, or endpoints.": "Capturez un ensemble réutilisable de modèles, d'étiquettes ou de points de terminaison.", "Card view": "Vue cartes", + "Catch-all route must be last for the same incoming path": "Le routage de secours doit être le dernier pour le même chemin d'entrée", "Category": "Catégorie", "Category Name": "Nom de la catégorie", "Category name is required": "Le nom de la catégorie est requis", @@ -867,6 +870,9 @@ "Click to view image": "Cliquer pour voir l’image", "Client header value": "Valeur d'en-tête client", "Client ID": "ID client", + "Client model": "Modèle client", + "Client model matching": "Correspondance du model client", + "Client model matching help": "Aide sur la correspondance du model client", "Client Secret": "Secret client", "Close": "Fermer", "Close dialog": "Fermer la boîte de dialogue", @@ -1446,6 +1452,7 @@ "Drawing task records": "Historique des tâches de dessin", "Duplicate": "Dupliquer", "Duplicate group names: {{names}}": "Noms de groupe en double : {{names}}", + "Duplicate model in route models": "Modèle dupliqué dans les modèles de route", "Duplicate source model mappings are not allowed": "Les mappages de modèles source en double ne sont pas autorisés", "Duplicate source model(s): {{models}}": "Modèle(s) source en double : {{models}}", "Duration": "Durée", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "ex. Plan de base", "e.g. Clean tool parameters to avoid upstream validation errors": "ex. Nettoyer les paramètres d'outils pour éviter les erreurs de validation en amont", "e.g. example.com": "par ex. example.com", + "e.g. gpt-4o, gemini-2.5-flash": "p. ex. gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "p. ex. llama3.1:8b", "e.g. My GitLab": "par ex. Mon GitLab", "e.g. my-gitlab": "par ex. mon-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "Toute la configuration de ce groupe, au même endroit.", "Exact": "Exact", "Exact Match": "Correspondance exacte", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Correspondance exacte uniquement, sensible à la casse. Les préfixes, regex et jokers * ne sont pas pris en charge.", "Example": "Exemple", "Example (all channels):": "Exemple (tous les canaux) :", "Example (specific channels):": "Exemple (canaux spécifiques) :", @@ -1903,7 +1912,11 @@ "Failed to update user": "Échec de la mise à jour de l'utilisateur", "Failure keywords": "Mots-clés d'échec", "Fair": "Correct", + "Fallback": "Repli", "Fallback base URL": "Base URL de fallback", + "Fallback for remaining models": "Repli pour les modèles restants", + "Fallback must be last": "Repli en dernier", + "Fallback route": "Route de repli", "Fallback tier": "Palier de repli", "FAQ": "FAQ", "FAQ added. Click \"Save Settings\" to apply.": "FAQ ajouté. Cliquez sur \"Enregistrer les paramètres\" pour appliquer.", @@ -1941,6 +1954,7 @@ "Fill Related Models": "Remplir les modèles associés", "Fill Template": "Remplir le modèle", "Fill Templates": "Remplir les modèles", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Saisissez la valeur model complète du corps de requête client, par exemple gpt-4o ou gemini-2.5-flash. Séparez plusieurs modèles par des virgules.", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Remplit thoughtSignature uniquement pour les canaux Gemini/Vertex utilisant le format OpenAI", "Filled {{count}} model(s)": "{{count}} modèle(s) rempli(s)", "Filled {{count}} related model(s)": "{{count}} modèle(s) associé(s) rempli(s)", @@ -1982,6 +1996,7 @@ "First token": "1er token", "First/Last Frame to Video": "Première/Dernière image vers vidéo", "Fix Abilities": "Réparer la cohérence des canaux", + "Fix order": "Corriger l’ordre", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "Cohérence des canaux réparée : {{success}} réussie(s), {{fails}} échouée(s)", "Fixed price": "Prix fixe", "Fixed price (USD)": "Prix fixe (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "Laissez vide pour conserver l'identifiant existant", "Leave blank to keep the existing key": "Laisser vide pour conserver la clé existante", "Leave blank unless rotating the secret": "Laissez vide, sauf si vous faites pivoter le secret", + "Leave empty for fallback": "Laisser vide pour le repli", "Leave empty for never expires": "Laissez vide pour qu'il n'expire jamais", + "Leave empty only for the final fallback split.": "Laissez vide uniquement pour la dernière branche de repli.", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Laissez vide pour désactiver l'exigence d'accord. Prend en charge Markdown, HTML ou une URL complète pour rediriger les utilisateurs.", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Laissez vide pour désactiver l'exigence de politique de confidentialité. Prend en charge Markdown, HTML ou une URL complète pour rediriger les utilisateurs.", "Leave empty to disband the tag": "Laissez vide pour dissoudre l'étiquette", "Leave empty to keep existing key": "Laissez vide pour conserver la clé existante", "Leave empty to keep unchanged": "Laissez vide pour conserver inchangé", + "Leave empty to match all models": "Laisser vide pour correspondre à tous les modèles", "Leave empty to use account email": "Laissez vide pour utiliser l'e-mail du compte", "Leave empty to use default": "Laisser vide pour utiliser la valeur par défaut", "Leave empty to use system temp directory": "Laisser vide pour utiliser le répertoire temporaire", "Leave empty to use username": "Laissez vide pour utiliser le nom d'utilisateur", + "Leave the final split empty as the fallback for models not matched above.": "Laissez la dernière répartition vide comme solution de secours pour les modèles non appariés plus haut.", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "Laissez vide uniquement pour la dernière branche de repli ; elle reçoit les modèles client non associés plus haut.", "Left to Right": "De gauche à droite", "Legacy Format (JSON Object)": "Ancien format (objet JSON)", "Legacy format must be a JSON object": "L'ancien format doit être un objet JSON", @@ -2482,6 +2502,9 @@ "Loading...": "Chargement...", "Local": "Local", "Local Billing": "Facturation locale", + "Local Estimate (billing-usage-anthropic)": "Estimation locale (billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "Estimation locale (billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "Estimation locale (billing-usage-openai)", "Local models": "Modèles locaux", "Locations": "Emplacements", "Locked": "Verrouillé", @@ -2548,7 +2571,9 @@ "Match Value": "Valeur de correspondance", "Match Value (optional)": "Valeur de correspondance (optionnel)", "Matched": "Correspondant", + "Matched models": "Modèles associés", "Matched Tier": "Palier correspondant", + "Matches models not claimed by earlier splits.": "Correspond aux modèles non pris par les branches précédentes.", "Matching Rules": "Règles de correspondance", "Max Disk Cache Size (MB)": "Taille max du cache disque (Mo)", "Max Entries": "Entrées max", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "Ratios des modèles réinitialisés avec succès", "Model Regex": "Regex du modèle", "Model Regex (one per line)": "Regex du modèle (un par ligne)", + "Model regex cannot be empty": "La regex du modèle ne peut pas être vide", + "Model scope": "Portée des modèles", "Model selected": "Modèle sélectionné", "Model Square": "Place des modèles", "Model Tags": "Tags de modèle", "Model to use for testing": "Modèle à utiliser pour les tests", "Model to use when testing channel connectivity": "Modèle à utiliser lors du test de la connectivité du canal", "Model Version *": "Version du modèle *", + "Model-scoped only": "Modèles uniquement", "model(s) selected out of": "modèle(s) sélectionné(s) parmi", "model(s)? This action cannot be undone.": "modèle(s) ? Cette action ne peut pas être annulée.", "models": "modèles", @@ -2714,9 +2742,12 @@ "Move": "Déplacer", "Move a request header": "Déplacer un en-tête de requête", "Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal", + "Move fallback to end": "Mettre le repli à la fin", "Move Field": "Déplacer le champ", "Move Header": "Déplacer l'en-tête", "Move Request Header": "Déplacer un en-tête de requête", + "Move route down": "Descendre la route", + "Move route up": "Monter la route", "Move source field to target field": "Déplacer le champ source vers le champ cible", "ms": "ms", "Multi-key channel: Keys will be": "Canal multi-clés : Les clés seront", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse du serveur.", "Only Mine": "Uniquement les miens", + "Only one catch-all route is allowed for the same incoming path": "Un seul routage de secours est autorisé pour le même chemin d'entrée", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.", "Only successful requests": "Uniquement les requêtes réussies", "Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses vers Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses vers OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, etc.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, etc.", @@ -3835,9 +3868,15 @@ "Route": "Route", "Route active": "Route active", "Route Description": "Description de la route", + "Route group": "Groupe de routes", "Route is required": "La route est requise", + "Route models must be unique for the same incoming path": "Les modèles de route doivent être uniques pour le même chemin d'entrée", "Route, auth, and balance check in one place": "Routage, authentification et solde au même endroit", "Routes": "Routes", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Les routes avec le même chemin d’entrée sont associées par modèle. Laissez la portée de modèles vide uniquement pour la route de repli finale.", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Les routes avec le même chemin entrant sont réparties selon les règles du model client. Les requêtes non appariées utilisent la dernière route de secours.", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Les routes avec le même chemin d’entrée sont réparties par modèle client exact. Les requêtes non associées utilisent le repli final.", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Les routes avec le même chemin d’entrée correspondent aux noms exacts des modèles client. Séparez plusieurs modèles par des virgules et laissez vide uniquement le repli final.", "Routing & Overrides": "Routage et surcharges", "Routing Reliability": "Fiabilité du routage", "Routing Strategy": "Stratégie de routage", @@ -3862,6 +3901,7 @@ "Rules": "Règles", "Rules JSON": "Règles JSON", "Rules JSON must be an array": "Le JSON des règles doit être un tableau", + "Rules match the original model value from the client request body.": "Les règles correspondent à la valeur model originale du corps de la requête client.", "Run GC": "Exécuter le GC", "Run tests for the selected models": "Exécuter les tests pour les modèles sélectionnés", "running": "en cours", @@ -4057,6 +4097,7 @@ "Sensitive Words": "Mots sensibles", "Sent the API key to FluentRead.": "Clé API envoyée à FluentRead.", "Separate image/audio prices are enabled.": "Les prix séparés pour l’image et l’audio sont activés.", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "Séparez plusieurs règles par des virgules anglaises. Pour les regex nécessitant des virgules, passez en JSON Text.", "Serve multiple users or teams with billing and quota control.": "Servir plusieurs utilisateurs ou équipes avec gestion de la facturation et des quotas.", "Server Address": "Adresse du serveur", "Server IP": "IP du serveur", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "Cette entrée de FAQ sera retirée de la liste.", "This feature is experimental. Configuration format and behavior may change.": "Cette fonctionnalité est expérimentale. Le format de configuration et le comportement peuvent changer.", "This feature requires server-side WeChat configuration": "Cette fonctionnalité nécessite une configuration WeChat côté serveur", + "This field does not support wildcards or regular expressions.": "Ce champ ne prend pas en charge les jokers ni les expressions régulières.", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Cet enregistrement historique date d'avant le suivi des informations d'audit et ne peut pas être complété rétroactivement. La version actuelle enregistre déjà l'IP du serveur, l'IP de rappel, le mode de paiement et la version du système pour les nouveaux paiements à venir.", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Cet identifiant est envoyé au backend de paiement lors de la création d’une commande. Utilisez alipay pour Alipay, wxpay pour WeChat Pay, stripe pour Stripe. Les valeurs personnalisées doivent être prises en charge par votre fournisseur de paiement.", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Cette instance utilise un nom d’hôte automatique. Définissez NODE_NAME sur une valeur stable et unique pour la gestion multi-instance.", @@ -4536,9 +4578,13 @@ "Timing": "Durée", "Tip": "Astuce", "to access this resource.": "pour accéder à cette ressource.", + "To Anthropic Messages": "Vers Anthropic Messages", "to confirm": "pour confirmer", + "To Gemini Generate Content": "Vers Gemini Generate Content", "To Lower": "En minuscules", "To Lowercase": "En minuscules", + "To OpenAI Chat": "Vers OpenAI Chat", + "To OpenAI Responses": "Vers OpenAI Responses", "to override billing when a user in one group uses a token of another group.": "pour remplacer la facturation lorsqu'un utilisateur d'un groupe utilise un jeton d'un autre groupe.", "to the Models list so users can use them before the mapping sends traffic upstream.": "à la liste des modèles afin que les utilisateurs puissent les utiliser avant que le mappage n'envoie le trafic en amont.", "To Upper": "En majuscules", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "Ratios en amont récupérés avec succès", "Upstream Request ID": "ID de requête en amont", "Upstream Response": "Réponse amont", + "Upstream Response (billing-usage-anthropic-estimated)": "Réponse amont (billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "Réponse amont (billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "Réponse amont (billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "Réponse amont (billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "Réponse amont (billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "Réponse amont (billing-usage-openai)", "upstream services integrated": "services en amont intégrés", "Upstream Updates": "Mises à jour en amont", "Upstream URL": "URL amont", @@ -4819,6 +4871,8 @@ "Use authenticator code": "Utiliser le code de l'authentificateur", "Use backup code": "Utiliser un code de secours", "Use disk cache when request body exceeds this size": "Utiliser le cache disque quand le corps de requête dépasse cette taille", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Utilisez les noms exacts des modèles client, séparés par des virgules. Les préfixes et jokers ne sont pas pris en charge.", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Utilisez des noms de modèle exacts comme gpt-4o, ou des règles regex préfixées par re: comme re:^gemini-.", "Use external tools to extend capabilities": "Utiliser des outils externes pour étendre les capacités", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Utilise un crédit de réinitialisation disponible pour ce canal. La demande n’est envoyée qu’après confirmation.", "Use one available reset credit to refresh the current Codex usage windows.": "Utilise un crédit de réinitialisation disponible pour actualiser les fenêtres d’utilisation Codex actuelles.", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 715ff04b6ac9..083547114f66 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -207,6 +207,7 @@ "Add rule group": "ルールグループを追加", "Add rules for a user group": "ユーザーグループにルールを追加", "Add selectable group": "選択可能なグループを追加", + "Add split": "分岐を追加", "Add subscription": "サブスクリプションを追加", "Add tags...": "タグを追加...", "Add tier": "ティアを追加", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "課金グループ = vip(トークンにグループがないのでユーザーグループを使用)", "Billing History": "請求履歴", "Billing Mode": "課金モード", + "Billing Path": "課金パス", "Billing Process": "課金プロセス", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "課金ルール:各呼び出しはトークングループとして課金されます(トークンにグループがない場合はユーザーグループにフォールバック)。基本倍率は常にその課金グループから取得され、ユーザーグループの倍率は適用されません。特定のユーザーグループに別の課金グループでの特別価格を設定するには、上書きマトリクスにエントリを追加してください。", "Billing Source": "課金ソース", @@ -715,6 +717,7 @@ "Caps the response length": "応答の長さを制限します", "Capture a reusable bundle of models, tags, or endpoints.": "モデル、タグ、またはエンドポイントの再利用可能なバンドルを保存。", "Card view": "カード表示", + "Catch-all route must be last for the same incoming path": "同じ入力パスのキャッチオールルートは最後に配置してください", "Category": "カテゴリ", "Category Name": "分類名称", "Category name is required": "カテゴリ名は必須です", @@ -867,6 +870,9 @@ "Click to view image": "クリックして画像を表示", "Client header value": "クライアントヘッダー値", "Client ID": "クライアントID", + "Client model": "クライアント model", + "Client model matching": "クライアント model のマッチング", + "Client model matching help": "クライアント model マッチングのヘルプ", "Client Secret": "クライアントシークレット", "Close": "閉じる", "Close dialog": "ダイアログを閉じる", @@ -1446,6 +1452,7 @@ "Drawing task records": "描画タスク記録", "Duplicate": "複製", "Duplicate group names: {{names}}": "重複するグループ名: {{names}}", + "Duplicate model in route models": "ルートモデルに重複したモデルがあります", "Duplicate source model mappings are not allowed": "重複したソースモデルのマッピングは許可されていません", "Duplicate source model(s): {{models}}": "重複したソースモデル: {{models}}", "Duration": "所要時間", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "例:ベーシックプラン", "e.g. Clean tool parameters to avoid upstream validation errors": "例:ツールパラメータを整理して上流の検証エラーを回避", "e.g. example.com": "例: example.com", + "e.g. gpt-4o, gemini-2.5-flash": "例: gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "例: llama3.1:8b", "e.g. My GitLab": "例: My GitLab", "e.g. my-gitlab": "例: my-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "このグループのすべての設定を一か所で確認できます。", "Exact": "完全一致", "Exact Match": "完全一致", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "完全一致のみで、大文字と小文字も区別します。プレフィックス、正規表現、* ワイルドカードは使えません。", "Example": "サンプル", "Example (all channels):": "例(全チャネル):", "Example (specific channels):": "例(特定チャネル):", @@ -1903,7 +1912,11 @@ "Failed to update user": "ユーザーの更新に失敗しました", "Failure keywords": "失敗キーワード", "Fair": "公平", + "Fallback": "フォールバック", "Fallback base URL": "フォールバック Base URL", + "Fallback for remaining models": "残りのモデル用フォールバック", + "Fallback must be last": "フォールバックは最後", + "Fallback route": "フォールバックルート", "Fallback tier": "フォールバック段階", "FAQ": "FAQ", "FAQ added. Click \"Save Settings\" to apply.": "FAQ が追加されました。「設定を保存」をクリックして適用してください。", @@ -1941,6 +1954,7 @@ "Fill Related Models": "関連モデルを入力", "Fill Template": "テンプレートを入力", "Fill Templates": "テンプレートを入力", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "クライアントリクエスト本文の完全な model 値を入力します。例: gpt-4o または gemini-2.5-flash。複数のモデルはカンマで区切ります。", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "OpenAI形式を利用するGemini/VertexチャネルにのみthoughtSignatureを付与します", "Filled {{count}} model(s)": "{{count}} 個のモデルを補完しました", "Filled {{count}} related model(s)": "{{count}} 個の関連モデルを補完しました", @@ -1982,6 +1996,7 @@ "First token": "先頭トークン", "First/Last Frame to Video": "先頭/末尾フレームから動画", "Fix Abilities": "チャネル整合性を修復", + "Fix order": "順序を修正", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件", "Fixed price": "固定価格", "Fixed price (USD)": "固定価格 (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "既存の認証情報を保持するには、空白のままにしてください", "Leave blank to keep the existing key": "空欄のままにすると既存のキーを保持します", "Leave blank unless rotating the secret": "シークレットをローテーションする場合を除き、空白のままにしてください", + "Leave empty for fallback": "フォールバックは空のまま", "Leave empty for never expires": "期限切れなしにするには空のままにしてください", + "Leave empty only for the final fallback split.": "空にできるのは最後のフォールバック分岐だけです。", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "利用規約の要件を無効にするには空のままにしてください。Markdown、HTML、またはユーザーをリダイレクトするための完全なURLをサポートします。", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "プライバシーポリシーの要件を無効にするには空のままにしてください。Markdown、HTML、またはユーザーをリダイレクトするための完全なURLをサポートします。", "Leave empty to disband the tag": "タグを解散するには空のままにしてください", "Leave empty to keep existing key": "空欄のままにすると既存のキーを保持します", "Leave empty to keep unchanged": "変更しない場合は空欄のまま", + "Leave empty to match all models": "空欄の場合はすべてのモデルに一致", "Leave empty to use account email": "アカウントのメールアドレスを使用するには空のままにしてください", "Leave empty to use default": "デフォルトを使用する場合は空欄にしてください", "Leave empty to use system temp directory": "空欄でシステムの一時ディレクトリを使用", "Leave empty to use username": "ユーザー名を使用するには空のままにしてください", + "Leave the final split empty as the fallback for models not matched above.": "上のルールに一致しないモデル用のフォールバックとして、最後の分岐を空にします。", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "空にできるのは最後のフォールバック分岐だけです。上の分岐で一致しないクライアントモデルを受けます。", "Left to Right": "左から右", "Legacy Format (JSON Object)": "旧形式(JSONオブジェクト)", "Legacy format must be a JSON object": "旧形式はJSONオブジェクトである必要があります", @@ -2482,6 +2502,9 @@ "Loading...": "読み込み中...", "Local": "ローカル", "Local Billing": "ローカル課金", + "Local Estimate (billing-usage-anthropic)": "ローカル推定 (billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "ローカル推定 (billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "ローカル推定 (billing-usage-openai)", "Local models": "ローカルモデル", "Locations": "場所", "Locked": "ロック済み", @@ -2548,7 +2571,9 @@ "Match Value": "マッチ値", "Match Value (optional)": "マッチ値(任意)", "Matched": "一致", + "Matched models": "一致モデル", "Matched Tier": "一致した階層", + "Matches models not claimed by earlier splits.": "前の分岐で使われていないモデルに一致します。", "Matching Rules": "マッチングルール", "Max Disk Cache Size (MB)": "ディスクキャッシュ最大容量 (MB)", "Max Entries": "最大エントリ数", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "モデル比率が正常にリセットされました", "Model Regex": "モデル正規表現", "Model Regex (one per line)": "モデル正規表現(1行に1つ)", + "Model regex cannot be empty": "モデル正規表現は空にできません", + "Model scope": "モデル範囲", "Model selected": "選択済みモデル", "Model Square": "モデル広場", "Model Tags": "モデルタグ", "Model to use for testing": "テストに使用するモデル", "Model to use when testing channel connectivity": "チャネル接続性をテストする際に使用するモデル", "Model Version *": "モデルバージョン *", + "Model-scoped only": "モデル指定のみ", "model(s) selected out of": "選択されたモデル", "model(s)? This action cannot be undone.": "モデルを削除しますか?この操作は元に戻せません。", "models": "モデル", @@ -2714,9 +2742,12 @@ "Move": "移動", "Move a request header": "リクエストヘッダーを移動", "Move affiliate rewards to your main balance": "アフィリエイト報酬をメイン残高に移動する", + "Move fallback to end": "フォールバックを最後へ", "Move Field": "フィールドを移動", "Move Header": "ヘッダーを移動", "Move Request Header": "リクエストヘッダーを移動", + "Move route down": "ルートを下へ", + "Move route up": "ルートを上へ", "Move source field to target field": "ソースフィールドをターゲットフィールドに移動", "ms": "ms", "Multi-key channel: Keys will be": "マルチキーチャネル: キーは", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。", "Only Mine": "自分のみ", + "Only one catch-all route is allowed for the same incoming path": "同じ入力パスではキャッチオールルートは1つだけ許可されます", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。", "Only successful requests": "成功したリクエストのみ", "Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "OpenAI 再ランク付け", "OpenAI Responses": "OpenAI レスポンス", "OpenAI Responses Compact": "OpenAI レスポンス圧縮", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses から Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses から OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI、Anthropicなど", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Googleなど", @@ -3835,9 +3868,15 @@ "Route": "ルート", "Route active": "ルート有効", "Route Description": "ルートの説明", + "Route group": "ルートグループ", "Route is required": "ルートは必須です", + "Route models must be unique for the same incoming path": "同じ入力パスではルートのモデルを一意にしてください", "Route, auth, and balance check in one place": "ルート、認証、残高確認を一か所に集約", "Routes": "ルート", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "同じ入口パスのルートはモデルで照合されます。モデル範囲を空にできるのは最後のフォールバックルートだけです。", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "同じ入口パスのルートはクライアント model ルールで分岐します。一致しないリクエストは最後のフォールバックを使います。", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同じ入口パスのルートは、クライアント model の完全一致で分岐します。一致しないリクエストは最後のフォールバックを使います。", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "同じ入口パスのルートは、クライアントの正確なモデル名で一致します。複数のモデルはカンマで区切り、最後のフォールバックだけを空にします。", "Routing & Overrides": "ルーティングと上書き", "Routing Reliability": "ルーティング信頼性", "Routing Strategy": "ルーティング戦略", @@ -3862,6 +3901,7 @@ "Rules": "ルール", "Rules JSON": "ルール JSON", "Rules JSON must be an array": "ルール JSON は配列である必要があります", + "Rules match the original model value from the client request body.": "ルールはクライアントリクエスト本文の元の model 値に一致します。", "Run GC": "GC 実行", "Run tests for the selected models": "選択したモデルのテストを実行", "running": "実行中", @@ -4057,6 +4097,7 @@ "Sensitive Words": "機密語", "Sent the API key to FluentRead.": "API キーを FluentRead に送信しました。", "Separate image/audio prices are enabled.": "画像/音声の個別料金が有効です。", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "複数のルールは半角カンマで区切ります。カンマが必要な正規表現は JSON Text に切り替えてください。", "Serve multiple users or teams with billing and quota control.": "課金とクォータ管理で複数のユーザーやチームにサービスを提供します。", "Server Address": "サーバーURL", "Server IP": "サーバー IP", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "この FAQ 項目はリストから削除されます。", "This feature is experimental. Configuration format and behavior may change.": "この機能は実験的です。設定フォーマットや動作は変更される可能性があります。", "This feature requires server-side WeChat configuration": "この機能にはサーバー側のWeChat設定が必要です", + "This field does not support wildcards or regular expressions.": "このフィールドではワイルドカードや正規表現は使えません。", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "このレコードは監査情報の記録に対応する前の履歴データのため、監査情報がありません。現在のバージョンではサーバーIP、コールバックIP、支払い方法、システムバージョンなどの監査情報を記録できますが、これらは今後新しく作成されるレコードにのみ適用され、過去のレコードを遡って補完することはできません。", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "注文作成時に、この識別子が決済バックエンドへ送信されます。Alipay は alipay、WeChat Pay は wxpay、Stripe は stripe を使ってください。カスタム値は決済サービス側で対応している必要があります。", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "このインスタンスは自動ホスト名を使用しています。マルチインスタンス管理のために、安定した一意の NODE_NAME を設定してください。", @@ -4536,9 +4578,13 @@ "Timing": "所要時間", "Tip": "ヒント", "to access this resource.": "このリソースにアクセスするには。", + "To Anthropic Messages": "Anthropic Messages へ", "to confirm": "確認する", + "To Gemini Generate Content": "Gemini Generate Content へ", "To Lower": "小文字に変換", "To Lowercase": "小文字化", + "To OpenAI Chat": "OpenAI Chat へ", + "To OpenAI Responses": "OpenAI Responses へ", "to override billing when a user in one group uses a token of another group.": "あるグループのユーザーが別のグループのトークンを使用する場合に、請求を上書きするため。", "to the Models list so users can use them before the mapping sends traffic upstream.": "マッピングがトラフィックをアップストリームに送信する前にユーザーが使用できるように、モデルリストに追加します。", "To Upper": "大文字に変換", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "アップストリーム比率が正常に取得されました", "Upstream Request ID": "上流リクエストID", "Upstream Response": "アップストリームレスポンス", + "Upstream Response (billing-usage-anthropic-estimated)": "アップストリームレスポンス (billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "アップストリームレスポンス (billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "アップストリームレスポンス (billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "アップストリームレスポンス (billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "アップストリームレスポンス (billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "アップストリームレスポンス (billing-usage-openai)", "upstream services integrated": "アップストリームサービス連携", "Upstream Updates": "アップストリーム更新", "Upstream URL": "上流 URL", @@ -4819,6 +4871,8 @@ "Use authenticator code": "認証コードを使用", "Use backup code": "バックアップコードを使用", "Use disk cache when request body exceeds this size": "リクエストボディがこのサイズを超えた場合にディスクキャッシュを使用", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "クライアントの正確なモデル名をカンマ区切りで入力します。プレフィックスやワイルドカードは使えません。", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "gpt-4o のような完全一致のモデル名、または re:^gemini- のように re: で始まる正規表現ルールを使えます。", "Use external tools to extend capabilities": "外部ツールを利用して機能を拡張", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "このチャンネルで利用可能なリセット回数を1回使用します。確認後にのみリセット要求を送信します。", "Use one available reset credit to refresh the current Codex usage windows.": "利用可能なリセット回数を1回使用して、現在の Codex 使用量ウィンドウを更新します。", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index 97c7e494caaf..724896b7f637 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -207,6 +207,7 @@ "Add rule group": "Добавить группу правил", "Add rules for a user group": "Добавить правила для группы пользователей", "Add selectable group": "Добавить выбираемую группу", + "Add split": "Добавить ветку", "Add subscription": "Добавить подписку", "Add tags...": "Добавить теги...", "Add tier": "Добавить уровень", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "Тарифная группа = vip (у токена нет группы, используем группу пользователя)", "Billing History": "История биллинга", "Billing Mode": "Режим биллинга", + "Billing Path": "Путь тарификации", "Billing Process": "Процесс тарификации", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Правило тарификации: каждый вызов тарифицируется по группе токена (если у токена нет группы — по группе пользователя). Базовый коэффициент всегда берётся из этой тарифной группы, а не из группы пользователя. Чтобы задать группе пользователей особую цену для другой тарифной группы, добавьте запись в матрицу переопределений.", "Billing Source": "Источник биллинга", @@ -715,6 +717,7 @@ "Caps the response length": "Ограничивает длину ответа", "Capture a reusable bundle of models, tags, or endpoints.": "Создайте повторно используемый набор моделей, тегов или конечных точек.", "Card view": "Карточки", + "Catch-all route must be last for the same incoming path": "Резервный маршрут должен быть последним для этого входного пути", "Category": "Категория", "Category Name": "Название категории", "Category name is required": "Название категории обязательно", @@ -867,6 +870,9 @@ "Click to view image": "Нажмите, чтобы просмотреть изображение", "Client header value": "Значение заголовка клиента", "Client ID": "ID клиента", + "Client model": "Модель клиента", + "Client model matching": "Сопоставление client model", + "Client model matching help": "Справка по сопоставлению client model", "Client Secret": "Секрет клиента", "Close": "Закрыть", "Close dialog": "Закрыть диалог", @@ -1446,6 +1452,7 @@ "Drawing task records": "Записи задач рисования", "Duplicate": "Дублировать", "Duplicate group names: {{names}}": "Повторяющиеся имена групп: {{names}}", + "Duplicate model in route models": "В моделях маршрута есть дубликат модели", "Duplicate source model mappings are not allowed": "Повторяющиеся сопоставления исходных моделей не допускаются", "Duplicate source model(s): {{models}}": "Повторяющиеся исходные модели: {{models}}", "Duration": "Длительность", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "напр. Базовый план", "e.g. Clean tool parameters to avoid upstream validation errors": "напр. Очистить параметры инструментов во избежание ошибок валидации", "e.g. example.com": "напр. example.com", + "e.g. gpt-4o, gemini-2.5-flash": "например, gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "например llama3.1:8b", "e.g. My GitLab": "например, My GitLab", "e.g. my-gitlab": "например, my-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "Все настройки этой группы в одном месте.", "Exact": "Точное", "Exact Match": "Точное совпадение", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Только точное совпадение с учетом регистра. Префиксы, регулярные выражения и подстановки * не поддерживаются.", "Example": "Пример", "Example (all channels):": "Пример (все каналы):", "Example (specific channels):": "Пример (указанные каналы):", @@ -1903,7 +1912,11 @@ "Failed to update user": "Не удалось обновить пользователя", "Failure keywords": "Ключевые слова сбоя", "Fair": "Удовлетворительно", + "Fallback": "Резерв", "Fallback base URL": "Base URL fallback", + "Fallback for remaining models": "Резерв для остальных моделей", + "Fallback must be last": "Резервный маршрут последним", + "Fallback route": "Резервный маршрут", "Fallback tier": "Резервный уровень", "FAQ": "Часто задаваемые вопросы", "FAQ added. Click \"Save Settings\" to apply.": "FAQ добавлен. Нажмите \"Сохранить настройки\" чтобы применить.", @@ -1941,6 +1954,7 @@ "Fill Related Models": "Заполнить связанные модели", "Fill Template": "Заполнить шаблон", "Fill Templates": "Заполнить шаблоны", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Укажите полное значение model из тела запроса клиента, например gpt-4o или gemini-2.5-flash. Несколько моделей разделяйте запятыми.", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Заполнять thoughtSignature только для каналов Gemini/Vertex, использующих формат OpenAI", "Filled {{count}} model(s)": "Заполнено {{count}} моделей", "Filled {{count}} related model(s)": "Заполнено {{count}} связанных моделей", @@ -1982,6 +1996,7 @@ "First token": "Первый токен", "First/Last Frame to Video": "Первый/последний кадр в видео", "Fix Abilities": "Восстановить согласованность каналов", + "Fix order": "Исправить порядок", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}", "Fixed price": "Фиксированная цена", "Fixed price (USD)": "Фиксированная цена (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "Оставьте пустым, чтобы сохранить существующие учетные данные", "Leave blank to keep the existing key": "Оставьте пустым, чтобы сохранить существующий ключ", "Leave blank unless rotating the secret": "Оставьте пустым, если не меняете секрет", + "Leave empty for fallback": "Оставьте пустым для резерва", "Leave empty for never expires": "Оставьте пустым для бессрочного действия", + "Leave empty only for the final fallback split.": "Оставляйте пустым только последнюю резервную ветку.", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Оставьте пустым, чтобы отключить требование соглашения. Поддерживает Markdown, HTML или полный URL для перенаправления пользователей.", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Оставьте пустым, чтобы отключить требование политики конфиденциальности. Поддерживает Markdown, HTML или полный URL для перенаправления пользователей.", "Leave empty to disband the tag": "Оставьте пустым, чтобы удалить тег", "Leave empty to keep existing key": "Оставьте пустым, чтобы сохранить существующий ключ", "Leave empty to keep unchanged": "Оставьте пустым, чтобы сохранить без изменений", + "Leave empty to match all models": "Оставьте пустым, чтобы сопоставлять все модели", "Leave empty to use account email": "Оставьте пустым, чтобы использовать электронную почту учетной записи", "Leave empty to use default": "Оставьте пустым для использования по умолчанию", "Leave empty to use system temp directory": "Оставьте пустым для системного временного каталога", "Leave empty to use username": "Оставьте пустым, чтобы использовать имя пользователя", + "Leave the final split empty as the fallback for models not matched above.": "Оставьте последний маршрут пустым как резервный для моделей, не совпавших выше.", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "Оставляйте пустым только последнюю резервную ветку; она принимает модели клиента, не совпавшие выше.", "Left to Right": "Слева направо", "Legacy Format (JSON Object)": "Старый формат (JSON-объект)", "Legacy format must be a JSON object": "Старый формат должен быть JSON-объектом", @@ -2482,6 +2502,9 @@ "Loading...": "Загрузка...", "Local": "Локальный", "Local Billing": "Локальная тарификация", + "Local Estimate (billing-usage-anthropic)": "Локальная оценка (billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "Локальная оценка (billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "Локальная оценка (billing-usage-openai)", "Local models": "Локальные модели", "Locations": "Местоположения", "Locked": "Заблокировано", @@ -2548,7 +2571,9 @@ "Match Value": "Значение сопоставления", "Match Value (optional)": "Значение сопоставления (необязательно)", "Matched": "Совпадение", + "Matched models": "Модели для сопоставления", "Matched Tier": "Подходящий уровень", + "Matches models not claimed by earlier splits.": "Совпадает с моделями, не занятыми предыдущими ветками.", "Matching Rules": "Правила сопоставления", "Max Disk Cache Size (MB)": "Макс. размер дискового кэша (МБ)", "Max Entries": "Макс. записей", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "Соотношения моделей успешно сброшены", "Model Regex": "Регулярное выражение модели", "Model Regex (one per line)": "Регулярное выражение модели (по одному на строку)", + "Model regex cannot be empty": "Регулярное выражение модели не может быть пустым", + "Model scope": "Область моделей", "Model selected": "Модель выбрана", "Model Square": "Витрина моделей", "Model Tags": "Теги моделей", "Model to use for testing": "Модель для использования при тестировании", "Model to use when testing channel connectivity": "Модель для использования при тестировании подключения канала", "Model Version *": "Версия модели *", + "Model-scoped only": "Только по моделям", "model(s) selected out of": "модель(и) выбрано из", "model(s)? This action cannot be undone.": "модель(и)? Это действие нельзя отменить.", "models": "моделей", @@ -2714,9 +2742,12 @@ "Move": "Переместить", "Move a request header": "Переместить заголовок запроса", "Move affiliate rewards to your main balance": "Перевести партнерские вознаграждения на основной баланс", + "Move fallback to end": "Переместить резерв в конец", "Move Field": "Переместить поле", "Move Header": "Переместить заголовок", "Move Request Header": "Переместить заголовок запроса", + "Move route down": "Переместить маршрут вниз", + "Move route up": "Переместить маршрут вверх", "Move source field to target field": "Переместить исходное поле в целевое", "ms": "мс", "Multi-key channel: Keys will be": "Многоключевой канал: Ключи будут", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.", "Only Mine": "Только мои", + "Only one catch-all route is allowed for the same incoming path": "Для одного входного пути разрешен только один резервный маршрут", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.", "Only successful requests": "Только успешные запросы", "Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "Реранжирование OpenAI", "OpenAI Responses": "Ответы OpenAI", "OpenAI Responses Compact": "Компактные ответы OpenAI", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses в Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses в OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic и т.д.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google и т.д.", @@ -3835,9 +3868,15 @@ "Route": "Маршрут", "Route active": "Маршрут активен", "Route Description": "Описание маршрута", + "Route group": "Группа маршрутов", "Route is required": "Маршрут обязателен", + "Route models must be unique for the same incoming path": "Модели маршрутов для одного входного пути должны быть уникальными", "Route, auth, and balance check in one place": "Маршрут, аутентификация и баланс в одном месте", "Routes": "Маршруты", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Маршруты с одним входным путем сопоставляются по модели. Оставляйте область моделей пустой только для последнего резервного маршрута.", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Маршруты с одним входным путем разделяются правилами client model. Неподходящие запросы используют последний резервный маршрут.", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Маршруты с одним входным путем разделяются по точной модели клиента. Несовпавшие запросы идут в последний резерв.", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Маршруты с одним входным путем сопоставляются с точными именами моделей клиента. Несколько моделей разделяйте запятыми, пустым оставляйте только последний резерв.", "Routing & Overrides": "Маршрутизация и переопределения", "Routing Reliability": "Надежность маршрутизации", "Routing Strategy": "Стратегия маршрутизации", @@ -3862,6 +3901,7 @@ "Rules": "Правила", "Rules JSON": "Правила JSON", "Rules JSON must be an array": "JSON правил должен быть массивом", + "Rules match the original model value from the client request body.": "Правила сопоставляются с исходным значением model из тела клиентского запроса.", "Run GC": "Запустить GC", "Run tests for the selected models": "Запустить тесты для выбранных моделей", "running": "выполняется", @@ -4057,6 +4097,7 @@ "Sensitive Words": "Чувствительные слова", "Sent the API key to FluentRead.": "API-ключ отправлен в FluentRead.", "Separate image/audio prices are enabled.": "Отдельные цены для изображений и аудио включены.", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "Разделяйте несколько правил английскими запятыми. Если regex нужны запятые, переключитесь на JSON Text.", "Serve multiple users or teams with billing and quota control.": "Обслуживание нескольких пользователей или команд с управлением биллингом и квотами.", "Server Address": "Адрес сервера", "Server IP": "IP сервера", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "Эта запись FAQ будет удалена из списка.", "This feature is experimental. Configuration format and behavior may change.": "Эта функция является экспериментальной. Формат конфигурации и поведение могут измениться.", "This feature requires server-side WeChat configuration": "Эта функция требует серверной конфигурации WeChat", + "This field does not support wildcards or regular expressions.": "Это поле не поддерживает подстановочные знаки или регулярные выражения.", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Эта историческая запись была создана до появления функции аудита и не содержит данных аудита. Текущая версия уже поддерживает запись IP-адреса сервера, IP обратного вызова, способа оплаты и версии системы, но эти поля будут заполняться только в новых записях — восполнить их в старых записях задним числом невозможно.", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Этот идентификатор отправляется в платежный backend при создании заказа. Для Alipay используйте alipay, для WeChat Pay — wxpay, для Stripe — stripe. Пользовательские значения должны поддерживаться вашим платежным провайдером.", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Этот экземпляр использует автоматическое имя хоста. Задайте стабильное уникальное значение NODE_NAME для управления несколькими экземплярами.", @@ -4536,9 +4578,13 @@ "Timing": "Время", "Tip": "Совет", "to access this resource.": "для доступа к этому ресурсу.", + "To Anthropic Messages": "В Anthropic Messages", "to confirm": "для подтверждения", + "To Gemini Generate Content": "В Gemini Generate Content", "To Lower": "В нижний регистр", "To Lowercase": "В нижний регистр", + "To OpenAI Chat": "В OpenAI Chat", + "To OpenAI Responses": "В OpenAI Responses", "to override billing when a user in one group uses a token of another group.": "для переопределения выставления счетов, когда пользователь одной группы использует токен другой группы.", "to the Models list so users can use them before the mapping sends traffic upstream.": "в список моделей, чтобы пользователи могли использовать их до того, как сопоставление отправит трафик выше по течению.", "To Upper": "В верхний регистр", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "Коэффициенты upstream успешно получены", "Upstream Request ID": "ID вышестоящего запроса", "Upstream Response": "Ответ Upstream", + "Upstream Response (billing-usage-anthropic-estimated)": "Ответ upstream (billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "Ответ upstream (billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "Ответ upstream (billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "Ответ upstream (billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "Ответ upstream (billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "Ответ upstream (billing-usage-openai)", "upstream services integrated": "интеграций с вышестоящими сервисами", "Upstream Updates": "Обновления вышестоящих моделей", "Upstream URL": "URL вышестоящего сервиса", @@ -4819,6 +4871,8 @@ "Use authenticator code": "Использовать код аутентификатора", "Use backup code": "Использовать резервный код", "Use disk cache when request body exceeds this size": "Использовать дисковый кэш, когда тело запроса превышает этот размер", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Укажите точные имена моделей клиента через запятую. Префиксы и подстановочные знаки не поддерживаются.", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Используйте точные имена моделей, например gpt-4o, или regex-правила с префиксом re:, например re:^gemini-.", "Use external tools to extend capabilities": "Использовать внешние инструменты для расширения возможностей", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Для этого канала будет использован один доступный сброс. Запрос отправляется только после подтверждения.", "Use one available reset credit to refresh the current Codex usage windows.": "Использует один доступный сброс, чтобы обновить текущие окна использования Codex.", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 39c0340ce2dc..ab15a0d096b9 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -207,6 +207,7 @@ "Add rule group": "Thêm nhóm quy tắc", "Add rules for a user group": "Thêm quy tắc cho nhóm người dùng", "Add selectable group": "Thêm nhóm có thể chọn", + "Add split": "Thêm nhánh", "Add subscription": "Thêm đăng ký", "Add tags...": "Thêm thẻ...", "Add tier": "Thêm bậc", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "Nhóm tính phí = vip (token không có nhóm nên dùng nhóm người dùng)", "Billing History": "Lịch sử thanh toán", "Billing Mode": "Chế độ thanh toán", + "Billing Path": "Đường dẫn tính phí", "Billing Process": "Quá trình tính phí", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "Quy tắc tính phí: mỗi cuộc gọi được tính phí theo nhóm token (nếu token không có nhóm thì dùng nhóm người dùng). Hệ số cơ bản luôn lấy từ nhóm tính phí đó, không phải từ nhóm người dùng. Để cho một nhóm người dùng giá đặc biệt trên nhóm tính phí khác, hãy thêm mục vào ma trận ghi đè.", "Billing Source": "Nguồn thanh toán", @@ -715,6 +717,7 @@ "Caps the response length": "Giới hạn độ dài phản hồi", "Capture a reusable bundle of models, tags, or endpoints.": "Đóng gói một bộ có thể tái sử dụng gồm các mô hình, thẻ hoặc điểm cuối.", "Card view": "Dạng thẻ", + "Catch-all route must be last for the same incoming path": "Tuyến dự phòng phải đứng cuối cho cùng đường dẫn đầu vào", "Category": "Danh mục", "Category Name": "Tên danh mục", "Category name is required": "Tên danh mục là bắt buộc", @@ -867,6 +870,9 @@ "Click to view image": "Nhấp để xem hình ảnh", "Client header value": "Giá trị header client", "Client ID": "Mã khách hàng", + "Client model": "Model phía client", + "Client model matching": "Khớp client model", + "Client model matching help": "Trợ giúp khớp client model", "Client Secret": "Bí mật máy khách", "Close": "Đóng", "Close dialog": "Đóng hộp thoại", @@ -1446,6 +1452,7 @@ "Drawing task records": "Lịch sử tác vụ vẽ", "Duplicate": "Nhân bản", "Duplicate group names: {{names}}": "Tên nhóm bị trùng: {{names}}", + "Duplicate model in route models": "Mô hình bị lặp trong danh sách mô hình tuyến", "Duplicate source model mappings are not allowed": "Không cho phép ánh xạ mô hình nguồn trùng lặp", "Duplicate source model(s): {{models}}": "Mô hình nguồn trùng lặp: {{models}}", "Duration": "Thời lượng", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "ví dụ: Gói cơ bản", "e.g. Clean tool parameters to avoid upstream validation errors": "ví dụ: Dọn dẹp tham số công cụ để tránh lỗi xác thực upstream", "e.g. example.com": "ví dụ example.com", + "e.g. gpt-4o, gemini-2.5-flash": "ví dụ gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "ví dụ: llama3.1:8b", "e.g. My GitLab": "ví dụ: GitLab của tôi", "e.g. my-gitlab": "ví dụ: my-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "Toàn bộ cấu hình của nhóm này, tại một nơi.", "Exact": "Chính xác", "Exact Match": "Khớp chính xác", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "Chỉ khớp chính xác và có phân biệt hoa thường. Không hỗ trợ tiền tố, regex hoặc ký tự đại diện *.", "Example": "Ví dụ", "Example (all channels):": "Ví dụ (tất cả kênh):", "Example (specific channels):": "Ví dụ (kênh cụ thể):", @@ -1903,7 +1912,11 @@ "Failed to update user": "Không thể cập nhật người dùng", "Failure keywords": "Từ khóa thất bại", "Fair": "Công bằng", + "Fallback": "Dự phòng", "Fallback base URL": "Base URL fallback", + "Fallback for remaining models": "Dự phòng cho mô hình còn lại", + "Fallback must be last": "Dự phòng phải cuối", + "Fallback route": "Tuyến dự phòng", "Fallback tier": "Tầng dự phòng", "FAQ": "FAQ", "FAQ added. Click \"Save Settings\" to apply.": "Đã thêm FAQ. Nhấp \"Lưu cài đặt\" để áp dụng.", @@ -1941,6 +1954,7 @@ "Fill Related Models": "Điền Mô hình Liên quan", "Fill Template": "Điền Mẫu", "Fill Templates": "Điền mẫu", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "Nhập đầy đủ giá trị model trong body yêu cầu của client, ví dụ gpt-4o hoặc gemini-2.5-flash. Ngăn cách nhiều model bằng dấu phẩy.", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "Điền thoughtSignature chỉ dành cho các kênh Gemini/Vertex sử dụng định dạng OpenAI", "Filled {{count}} model(s)": "Đã điền {{count}} mô hình", "Filled {{count}} related model(s)": "Đã điền {{count}} mô hình liên quan", @@ -1982,6 +1996,7 @@ "First token": "Token đầu", "First/Last Frame to Video": "Khung đầu/cuối sang video", "Fix Abilities": "Sửa tính nhất quán kênh", + "Fix order": "Sửa thứ tự", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "Đã sửa tính nhất quán kênh: {{success}} thành công, {{fails}} thất bại", "Fixed price": "Giá cố định", "Fixed price (USD)": "Giá cố định (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "Để trống để giữ thông tin xác thực hiện có", "Leave blank to keep the existing key": "Để trống để giữ khóa hiện có", "Leave blank unless rotating the secret": "Để trống trừ khi xoay vòng bí mật", + "Leave empty for fallback": "Để trống cho dự phòng", "Leave empty for never expires": "Để trống để không bao giờ hết hạn", + "Leave empty only for the final fallback split.": "Chỉ để trống cho nhánh dự phòng cuối cùng.", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Để trống để tắt yêu cầu đồng ý. Hỗ trợ Markdown, HTML hoặc một URL đầy đủ để chuyển hướng người dùng.", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "Để trống để vô hiệu hóa yêu cầu chính sách bảo mật. Hỗ trợ Markdown, HTML hoặc một URL đầy đủ để chuyển hướng người dùng.", "Leave empty to disband the tag": "Để trống để giải tán thẻ", "Leave empty to keep existing key": "Để trống để giữ khóa hiện có", "Leave empty to keep unchanged": "Để trống để giữ nguyên", + "Leave empty to match all models": "Để trống để khớp tất cả mô hình", "Leave empty to use account email": "Để trống để sử dụng email tài khoản", "Leave empty to use default": "Để trống để sử dụng mặc định", "Leave empty to use system temp directory": "Để trống để sử dụng thư mục tạm của hệ thống", "Leave empty to use username": "Để trống để sử dụng tên người dùng", + "Leave the final split empty as the fallback for models not matched above.": "Để nhánh cuối trống làm dự phòng cho các model chưa khớp ở trên.", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "Chỉ để trống cho nhánh dự phòng cuối cùng; nhánh này nhận các model client chưa khớp ở trên.", "Left to Right": "Trái sang phải", "Legacy Format (JSON Object)": "Định dạng cũ (đối tượng JSON)", "Legacy format must be a JSON object": "Định dạng cũ phải là đối tượng JSON", @@ -2482,6 +2502,9 @@ "Loading...": "Đang tải...", "Local": "Địa phương", "Local Billing": "Thanh toán nội địa", + "Local Estimate (billing-usage-anthropic)": "Ước tính cục bộ (billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "Ước tính cục bộ (billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "Ước tính cục bộ (billing-usage-openai)", "Local models": "Mô hình cục bộ", "Locations": "Vị trí", "Locked": "Đã khóa", @@ -2548,7 +2571,9 @@ "Match Value": "Giá trị khớp", "Match Value (optional)": "Giá trị khớp (tùy chọn)", "Matched": "Đã khớp", + "Matched models": "Mô hình khớp", "Matched Tier": "Bậc khớp", + "Matches models not claimed by earlier splits.": "Khớp các mô hình chưa được nhánh trước nhận.", "Matching Rules": "Quy tắc khớp", "Max Disk Cache Size (MB)": "Dung lượng tối đa bộ nhớ đệm đĩa (MB)", "Max Entries": "Số mục tối đa", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "Tỷ lệ mô hình đã được đặt lại thành công", "Model Regex": "Regex mô hình", "Model Regex (one per line)": "Regex mô hình (mỗi dòng một mục)", + "Model regex cannot be empty": "Regex model không được để trống", + "Model scope": "Phạm vi mô hình", "Model selected": "Đã chọn mô hình", "Model Square": "Quảng trường mô hình", "Model Tags": "Thẻ mô hình", "Model to use for testing": "Mô hình dùng để kiểm thử", "Model to use when testing channel connectivity": "Mô hình để sử dụng khi kiểm tra kết nối kênh", "Model Version *": "Phiên bản mô hình *", + "Model-scoped only": "Chỉ theo mô hình", "model(s) selected out of": "mô hình(s) được chọn trong số", "model(s)? This action cannot be undone.": "mô hình(s)? Hành động này không thể hoàn tác.", "models": "mô hình", @@ -2714,9 +2742,12 @@ "Move": "Di chuyển", "Move a request header": "Di chuyển header yêu cầu", "Move affiliate rewards to your main balance": "Chuyển phần thưởng liên kết vào số dư chính của bạn", + "Move fallback to end": "Đưa dự phòng xuống cuối", "Move Field": "Di chuyển trường", "Move Header": "Di chuyển tiêu đề", "Move Request Header": "Di chuyển header yêu cầu", + "Move route down": "Di chuyển tuyến xuống", + "Move route up": "Di chuyển tuyến lên", "Move source field to target field": "Di chuyển trường nguồn sang trường đích", "ms": "ms", "Multi-key channel: Keys will be": "Kênh đa khóa: Các khóa sẽ là", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.", "Only Mine": "Chỉ của tôi", + "Only one catch-all route is allowed for the same incoming path": "Mỗi đường dẫn đầu vào chỉ được có một tuyến dự phòng", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.", "Only successful requests": "Chỉ các yêu cầu thành công", "Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "OpenAI Rerank", "OpenAI Responses": "OpenAI Responses", "OpenAI Responses Compact": "OpenAI Responses Compact", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses sang Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses sang OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI, Anthropic, v.v.", "OpenAI, Anthropic, Google, etc.": "OpenAI, Anthropic, Google, v.v.", @@ -3835,9 +3868,15 @@ "Route": "Tuyến đường", "Route active": "Tuyến đang hoạt động", "Route Description": "Mô tả lộ trình", + "Route group": "Nhóm tuyến", "Route is required": "Đường dẫn là bắt buộc", + "Route models must be unique for the same incoming path": "Các mô hình tuyến phải là duy nhất cho cùng đường dẫn đầu vào", "Route, auth, and balance check in one place": "Kiểm tra tuyến, xác thực và số dư ở cùng một nơi", "Routes": "Route", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "Các tuyến có cùng đường dẫn vào được khớp theo mô hình. Chỉ để trống phạm vi mô hình cho tuyến dự phòng cuối cùng.", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "Các route có cùng đường vào được phân nhánh theo quy tắc client model. Yêu cầu không khớp dùng nhánh dự phòng cuối.", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "Các tuyến có cùng đường dẫn vào được tách theo model client chính xác. Yêu cầu chưa khớp sẽ dùng nhánh dự phòng cuối cùng.", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "Các tuyến có cùng đường dẫn vào khớp theo tên model chính xác từ yêu cầu client. Ngăn cách nhiều model bằng dấu phẩy và chỉ để trống nhánh dự phòng cuối cùng.", "Routing & Overrides": "Định tuyến & ghi đè", "Routing Reliability": "Độ tin cậy định tuyến", "Routing Strategy": "Chiến lược định tuyến", @@ -3862,6 +3901,7 @@ "Rules": "Quy tắc", "Rules JSON": "JSON quy tắc", "Rules JSON must be an array": "JSON quy tắc phải là một mảng", + "Rules match the original model value from the client request body.": "Quy tắc khớp với giá trị model gốc trong thân yêu cầu của client.", "Run GC": "Chạy GC", "Run tests for the selected models": "Chạy kiểm thử cho các mô hình đã chọn", "running": "đang chạy", @@ -4057,6 +4097,7 @@ "Sensitive Words": "Từ ngữ nhạy cảm", "Sent the API key to FluentRead.": "Đã gửi khóa API đến FluentRead.", "Separate image/audio prices are enabled.": "Giá riêng cho hình ảnh/âm thanh đã được bật.", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "Tách nhiều quy tắc bằng dấu phẩy tiếng Anh. Với regex cần dấu phẩy, hãy chuyển sang JSON Text.", "Serve multiple users or teams with billing and quota control.": "Phục vụ nhiều người dùng hoặc nhóm với quản lý thanh toán và hạn mức.", "Server Address": "Địa chỉ máy chủ", "Server IP": "IP máy chủ", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "Mục FAQ này sẽ bị xóa khỏi danh sách.", "This feature is experimental. Configuration format and behavior may change.": "Tính năng này đang ở giai đoạn thử nghiệm. Định dạng cấu hình và hành vi có thể thay đổi.", "This feature requires server-side WeChat configuration": "Tính năng này yêu cầu cấu hình WeChat phía máy chủ", + "This field does not support wildcards or regular expressions.": "Trường này không hỗ trợ ký tự đại diện hoặc biểu thức chính quy.", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "Bản ghi lịch sử này được tạo trước khi tính năng thông tin kiểm toán ra đời nên thiếu dữ liệu kiểm toán. Phiên bản hiện tại đã hỗ trợ ghi lại IP máy chủ, IP gọi lại, phương thức thanh toán và phiên bản hệ thống, nhưng các trường này chỉ được ghi cho các bản ghi mới về sau — không thể bổ sung hồi tố cho bản ghi cũ.", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "Mã định danh này được gửi tới backend thanh toán khi tạo đơn hàng. Dùng alipay cho Alipay, wxpay cho WeChat Pay, stripe cho Stripe. Giá trị tùy chỉnh phải được nhà cung cấp thanh toán hỗ trợ.", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "Phiên bản này đang dùng hostname tự động. Hãy đặt NODE_NAME thành một giá trị ổn định và duy nhất để quản lý nhiều phiên bản.", @@ -4536,9 +4578,13 @@ "Timing": "Thời gian", "Tip": "Mẹo", "to access this resource.": "để truy cập tài nguyên này.", + "To Anthropic Messages": "Sang Anthropic Messages", "to confirm": "Chờ xác nhận", + "To Gemini Generate Content": "Sang Gemini Generate Content", "To Lower": "Chữ thường", "To Lowercase": "Chuyển chữ thường", + "To OpenAI Chat": "Sang OpenAI Chat", + "To OpenAI Responses": "Sang OpenAI Responses", "to override billing when a user in one group uses a token of another group.": "để ghi đè việc thanh toán khi một người dùng trong một nhóm sử dụng token của một nhóm khác.", "to the Models list so users can use them before the mapping sends traffic upstream.": "vào danh sách Mô hình để người dùng có thể sử dụng chúng trước khi ánh xạ gửi lưu lượng truy cập lên phía trên.", "To Upper": "Chữ hoa", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "Đã lấy tỷ lệ upstream thành công", "Upstream Request ID": "ID yêu cầu thượng nguồn", "Upstream Response": "Upstream feedback", + "Upstream Response (billing-usage-anthropic-estimated)": "Phản hồi upstream (billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "Phản hồi upstream (billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "Phản hồi upstream (billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "Phản hồi upstream (billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "Phản hồi upstream (billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "Phản hồi upstream (billing-usage-openai)", "upstream services integrated": "dịch vụ thượng nguồn tích hợp", "Upstream Updates": "Cập nhật nguồn", "Upstream URL": "URL upstream", @@ -4819,6 +4871,8 @@ "Use authenticator code": "Sử dụng mã xác thực", "Use backup code": "Sử dụng mã dự phòng", "Use disk cache when request body exceeds this size": "Sử dụng bộ nhớ đệm đĩa khi nội dung yêu cầu vượt quá kích thước này", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "Nhập tên model chính xác từ yêu cầu client, ngăn cách bằng dấu phẩy. Không hỗ trợ tiền tố hoặc ký tự đại diện.", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "Dùng tên model chính xác như gpt-4o, hoặc quy tắc regex có tiền tố re: như re:^gemini-.", "Use external tools to extend capabilities": "Sử dụng công cụ ngoài để mở rộng khả năng", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "Sử dụng một lượt đặt lại khả dụng cho kênh này. Yêu cầu chỉ được gửi sau khi xác nhận.", "Use one available reset credit to refresh the current Codex usage windows.": "Sử dụng một lượt đặt lại khả dụng để làm mới các cửa sổ mức dùng Codex hiện tại.", diff --git a/web/default/src/i18n/locales/zh-TW.json b/web/default/src/i18n/locales/zh-TW.json index 8e1cb6374119..e317e74264b4 100644 --- a/web/default/src/i18n/locales/zh-TW.json +++ b/web/default/src/i18n/locales/zh-TW.json @@ -207,6 +207,7 @@ "Add rule group": "新增規則組", "Add rules for a user group": "為用戶分組新增規則", "Add selectable group": "新增可選分組", + "Add split": "新增分流", "Add subscription": "新增訂閱", "Add tags...": "新增標籤...", "Add tier": "新增檔位", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "收費分組 = vip(令牌沒設定分組,就用用戶自己的分組)", "Billing History": "收費歷史", "Billing Mode": "收費模式", + "Billing Path": "收費路徑", "Billing Process": "收費過程", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "收費規則:每次呼叫按令牌分組收費(令牌未設定分組時回退到用戶分組)。基礎倍率始終取該收費分組的倍率,而不是用戶分組的倍率。若要讓某用戶分組在使用其他收費分組時享受特殊價格,請在覆蓋矩陣中添加條目。", "Billing Source": "收費來源", @@ -715,6 +717,7 @@ "Caps the response length": "限制回覆長度", "Capture a reusable bundle of models, tags, or endpoints.": "捕捉可重用的模型、標籤或端點捆綁包。", "Card view": "卡片檢視", + "Catch-all route must be last for the same incoming path": "同一入口路徑的兜底路由必須放在最後", "Category": "分類", "Category Name": "分類名稱", "Category name is required": "分類名稱不能為空", @@ -867,6 +870,9 @@ "Click to view image": "點擊查看圖片", "Client header value": "用戶端請求頭值", "Client ID": "Client ID", + "Client model": "客戶端 model", + "Client model matching": "客戶端 model 匹配", + "Client model matching help": "客戶端 model 匹配幫助", "Client Secret": "Client Secret", "Close": "關閉", "Close dialog": "關閉對話框", @@ -1446,6 +1452,7 @@ "Drawing task records": "繪圖任務記錄", "Duplicate": "重複", "Duplicate group names: {{names}}": "存在重複的分組名稱:{{names}}", + "Duplicate model in route models": "路由模型中存在重複模型", "Duplicate source model mappings are not allowed": "不允許重複的源模型映射", "Duplicate source model(s): {{models}}": "重複的源模型:{{models}}", "Duration": "耗時", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "例如:基礎套餐", "e.g. Clean tool parameters to avoid upstream validation errors": "例如:清理工具參數,避免上游校驗錯誤", "e.g. example.com": "例如,example.com", + "e.g. gpt-4o, gemini-2.5-flash": "例如 gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "例如 llama3.1:8b", "e.g. My GitLab": "例如:My GitLab", "e.g. my-gitlab": "例如:my-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "該分組的全部設定,一處看全。", "Exact": "精確", "Exact Match": "完全匹配", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "只做完整精確匹配,區分大小寫;不支援前綴、正則或 * 萬用字元。", "Example": "示例", "Example (all channels):": "示例(全部渠道):", "Example (specific channels):": "示例(指定渠道):", @@ -1903,7 +1912,11 @@ "Failed to update user": "更新用戶失敗", "Failure keywords": "失敗關鍵詞", "Fair": "公平", + "Fallback": "兜底", "Fallback base URL": "兜底 Base URL", + "Fallback for remaining models": "留空匹配剩餘模型", + "Fallback must be last": "兜底必須在最後", + "Fallback route": "兜底路由", "Fallback tier": "兜底階梯", "FAQ": "常見問答", "FAQ added. Click \"Save Settings\" to apply.": "FAQ 已新增。點擊「儲存設定」以套用。", @@ -1941,6 +1954,7 @@ "Fill Related Models": "填入相關模型", "Fill Template": "填入模板", "Fill Templates": "填充模板", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "填寫客戶端請求體裡的完整 model 值,例如 gpt-4o 或 gemini-2.5-flash。多個模型用英文逗號分隔。", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "僅為使用 OpenAI 格式的 Gemini/Vertex 渠道填充 thoughtSignature", "Filled {{count}} model(s)": "已填充 {{count}} 個模型", "Filled {{count}} related model(s)": "已填充 {{count}} 個關聯模型", @@ -1982,6 +1996,7 @@ "First token": "首字", "First/Last Frame to Video": "首尾生影片", "Fix Abilities": "修復渠道一致性", + "Fix order": "修復順序", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修復完成:{{success}} 個成功,{{fails}} 個失敗", "Fixed price": "固定價格", "Fixed price (USD)": "固定價格 (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "留空以保留現有憑證", "Leave blank to keep the existing key": "留空以保留現有金鑰", "Leave blank unless rotating the secret": "除非正在輪換金鑰,否則留空", + "Leave empty for fallback": "留空作為兜底", "Leave empty for never expires": "留空表示永不失效", + "Leave empty only for the final fallback split.": "只有最後一個兜底分流可以留空。", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "留空以停用協議要求。支援 Markdown、HTML 或用於重新導向用戶的完整 URL。", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "留空以停用隱私政策要求。支援 Markdown、HTML 或用於重新導向用戶的完整 URL。", "Leave empty to disband the tag": "留空以解散標籤", "Leave empty to keep existing key": "留空以保留現有金鑰", "Leave empty to keep unchanged": "留空以保持不變", + "Leave empty to match all models": "留空匹配所有模型", "Leave empty to use account email": "留空以使用用戶電郵", "Leave empty to use default": "留空使用預設", "Leave empty to use system temp directory": "留空使用系統臨時目錄", "Leave empty to use username": "留空以使用用戶名", + "Leave the final split empty as the fallback for models not matched above.": "將最後一個分流留空作為兜底,用於匹配前面未命中的模型。", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "只有最後一個兜底分流可以留空;它會匹配前面未命中的客戶端模型。", "Left to Right": "從左到右", "Legacy Format (JSON Object)": "舊格式(JSON 物件)", "Legacy format must be a JSON object": "舊格式必須是 JSON 物件", @@ -2482,6 +2502,9 @@ "Loading...": "載入中...", "Local": "本地", "Local Billing": "本地收費", + "Local Estimate (billing-usage-anthropic)": "本地估算(billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "本地估算(billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "本地估算(billing-usage-openai)", "Local models": "本地模型", "Locations": "位置", "Locked": "鎖定", @@ -2548,7 +2571,9 @@ "Match Value": "匹配值", "Match Value (optional)": "匹配值(可選)", "Matched": "已命中", + "Matched models": "匹配模型", "Matched Tier": "命中階梯", + "Matches models not claimed by earlier splits.": "匹配前面分流未佔用的模型。", "Matching Rules": "匹配規則", "Max Disk Cache Size (MB)": "磁碟緩存最大總量 (MB)", "Max Entries": "最大條目數", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "模型比例重置成功", "Model Regex": "模型正則", "Model Regex (one per line)": "模型正則(每行一個)", + "Model regex cannot be empty": "模型正則不能為空", + "Model scope": "模型範圍", "Model selected": "已選擇模型", "Model Square": "模型廣場", "Model Tags": "模型標籤", "Model to use for testing": "用於測試的模型", "Model to use when testing channel connectivity": "測試渠道連接時使用的模型", "Model Version *": "模型版本 *", + "Model-scoped only": "僅模型分流", "model(s) selected out of": "已選模型(共)", "model(s)? This action cannot be undone.": "模型?此操作無法撤銷。", "models": "個模型", @@ -2714,9 +2742,12 @@ "Move": "移動", "Move a request header": "移動請求頭", "Move affiliate rewards to your main balance": "將推廣獎勵轉移到您的主餘額", + "Move fallback to end": "兜底移到最後", "Move Field": "移動欄位", "Move Header": "移動請求頭", "Move Request Header": "移動請求頭", + "Move route down": "下移路由", + "Move route up": "上移路由", "Move source field to target field": "把來源欄位移動到目標欄位", "ms": "毫秒", "Multi-key channel: Keys will be": "多金鑰渠道:金鑰將", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。", "Only Mine": "僅自己", + "Only one catch-all route is allowed for the same incoming path": "同一入口路徑只允許一個兜底路由", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。", "Only successful requests": "僅成功的請求", "Only successful requests count toward this limit.": "僅成功的請求計入此限制。", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "OpenAI 重排序", "OpenAI Responses": "OpenAI 回應", "OpenAI Responses Compact": "OpenAI 回應壓縮", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses 轉 Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses 到 OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等", @@ -3835,9 +3868,15 @@ "Route": "路由", "Route active": "路由已啟用", "Route Description": "路由描述", + "Route group": "路由組", "Route is required": "路由為必填項", + "Route models must be unique for the same incoming path": "同一入口路徑下的路由模型必須唯一", "Route, auth, and balance check in one place": "路由、認證和餘額檢查集中展示", "Routes": "路由", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "同一入口路徑的路由按模型匹配。僅最後一個兜底路由可留空模型範圍。", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "同一入口路徑按客戶端 model 規則分流;未命中的請求走最後的兜底。", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同一入口路徑按客戶端 model 精確分流;未命中的請求走最後的兜底。", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "同一入口路徑按客戶端請求中的精確模型名匹配。多個模型用英文逗號分隔,只有最後的兜底可留空。", "Routing & Overrides": "路由與覆蓋", "Routing Reliability": "路由可靠性", "Routing Strategy": "路由策略", @@ -3862,6 +3901,7 @@ "Rules": "規則", "Rules JSON": "規則 JSON", "Rules JSON must be an array": "規則 JSON 必須是陣列", + "Rules match the original model value from the client request body.": "規則匹配客戶端請求體裡的原始 model 值。", "Run GC": "執行 GC", "Run tests for the selected models": "執行所選模型的測試", "running": "執行中", @@ -4057,6 +4097,7 @@ "Sensitive Words": "敏感詞", "Sent the API key to FluentRead.": "API 金鑰已發送至 FluentRead。", "Separate image/audio prices are enabled.": "已啟用圖像/音頻單獨定價。", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "多個規則用英文逗號分隔。正則裡需要逗號時,請切換到 JSON 文字。", "Serve multiple users or teams with billing and quota control.": "為多個用戶或團隊提供收費和配額管理服務。", "Server Address": "伺服器地址", "Server IP": "伺服器 IP", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "此 FAQ 條目將從列表中移除。", "This feature is experimental. Configuration format and behavior may change.": "此功能為實驗性功能。設定格式和行為可能會發生變化。", "This feature requires server-side WeChat configuration": "此功能需要伺服器端微信設定", + "This field does not support wildcards or regular expressions.": "這裡不支援萬用字元或正則表達式。", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "該條歷史記錄缺少審計欄位。目前版本已支援記錄伺服器 IP、Callback IP、支付方式與系統版本等審計資訊;這些欄位僅會寫入後續新產生的記錄,歷史記錄無法自動補齊。", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "建立訂單時會把這個標識提交給支付後端。支付寶填 alipay,微信填 wxpay,Stripe 填 stripe。自訂值必須是支付服務支援的標識。", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "該實例正在使用自動主機名稱。請設定穩定且唯一的 NODE_NAME,以便進行多實例管理。", @@ -4536,9 +4578,13 @@ "Timing": "耗時", "Tip": "提示", "to access this resource.": "存取此資源。", + "To Anthropic Messages": "轉 Anthropic Messages", "to confirm": "以確認", + "To Gemini Generate Content": "轉 Gemini Generate Content", "To Lower": "轉小寫", "To Lowercase": "轉小寫", + "To OpenAI Chat": "轉 OpenAI Chat", + "To OpenAI Responses": "轉 OpenAI Responses", "to override billing when a user in one group uses a token of another group.": "當一個分組中的用戶使用另一個分組的令牌時,用於覆蓋收費。", "to the Models list so users can use them before the mapping sends traffic upstream.": "到模型列表,以便用戶在映射將流量發送到上游之前可以使用它們。", "To Upper": "轉大寫", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "上游比率獲取成功", "Upstream Request ID": "上游請求 ID", "Upstream Response": "上游返回", + "Upstream Response (billing-usage-anthropic-estimated)": "上游返回(billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "上游返回(billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "上游返回(billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "上游返回(billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "上游返回(billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "上游返回(billing-usage-openai)", "upstream services integrated": "上游服務適配", "Upstream Updates": "上游更新", "Upstream URL": "上游 URL", @@ -4819,6 +4871,8 @@ "Use authenticator code": "使用驗證器代碼", "Use backup code": "使用備用代碼", "Use disk cache when request body exceeds this size": "請求體超過此大小時使用磁碟緩存", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "填寫客戶端請求裡的精確 model 名,多個用英文逗號分隔。不支援前綴或萬用字元。", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填寫 gpt-4o 這類精確模型名,也可以填寫 re:^gemini- 這類以 re: 開頭的正則規則。", "Use external tools to extend capabilities": "透過外部工具擴展能力", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "將為目前渠道使用 1 次可用重置次數。只有確認後才會發送重置請求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次數,重新整理目前 Codex 用量窗口。", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index ddf55d799de9..1a12c09fdb64 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -207,6 +207,7 @@ "Add rule group": "新增规则组", "Add rules for a user group": "为用户分组添加规则", "Add selectable group": "添加可选分组", + "Add split": "添加分流", "Add subscription": "新增订阅", "Add tags...": "添加标签...", "Add tier": "新增档位", @@ -617,6 +618,7 @@ "Billing group = vip (the token has no group, so use the user group)": "计费分组 = vip(令牌没设置分组,就用用户自己的分组)", "Billing History": "计费历史", "Billing Mode": "计费模式", + "Billing Path": "计费路径", "Billing Process": "计费过程", "Billing rule: each call is billed as the token group (falling back to the user group when the token has none). The base ratio always comes from that billing group, not from the user group. To give a user group a special price on another billing group, add an entry in the override matrix.": "计费规则:每次调用按令牌分组计费(令牌未设置分组时回退到用户分组)。基础倍率始终取该计费分组的倍率,而不是用户分组的倍率。若要让某用户分组在使用其他计费分组时享受特殊价格,请在覆盖矩阵中添加条目。", "Billing Source": "计费来源", @@ -715,6 +717,7 @@ "Caps the response length": "限制回复长度", "Capture a reusable bundle of models, tags, or endpoints.": "捕获可重用的模型、标签或端点捆绑包。", "Card view": "卡片视图", + "Catch-all route must be last for the same incoming path": "同一入口路径的兜底路由必须放在最后", "Category": "分类", "Category Name": "分类名称", "Category name is required": "分类名称不能为空", @@ -867,6 +870,9 @@ "Click to view image": "点击查看图片", "Client header value": "客户端请求头值", "Client ID": "Client ID", + "Client model": "客户端 model", + "Client model matching": "客户端 model 匹配", + "Client model matching help": "客户端 model 匹配帮助", "Client Secret": "Client Secret", "Close": "关闭", "Close dialog": "关闭对话框", @@ -1446,6 +1452,7 @@ "Drawing task records": "绘图任务记录", "Duplicate": "重复", "Duplicate group names: {{names}}": "存在重复的分组名称:{{names}}", + "Duplicate model in route models": "路由模型中存在重复模型", "Duplicate source model mappings are not allowed": "不允许重复的源模型映射", "Duplicate source model(s): {{models}}": "重复的源模型:{{models}}", "Duration": "耗时", @@ -1460,6 +1467,7 @@ "e.g. Basic Plan": "例如:基础套餐", "e.g. Clean tool parameters to avoid upstream validation errors": "例如:清理工具参数,避免上游校验错误", "e.g. example.com": "例如,example.com", + "e.g. gpt-4o, gemini-2.5-flash": "例如 gpt-4o, gemini-2.5-flash", "e.g. llama3.1:8b": "例如 llama3.1:8b", "e.g. My GitLab": "例如:My GitLab", "e.g. my-gitlab": "例如:my-gitlab", @@ -1717,6 +1725,7 @@ "Everything configured for this group, in one place.": "该分组的全部配置,一处看全。", "Exact": "精确", "Exact Match": "完全匹配", + "Exact match only and case-sensitive. Prefixes, regex, and * wildcards are not supported.": "只做完整精确匹配,区分大小写;不支持前缀、正则或 * 通配。", "Example": "示例", "Example (all channels):": "示例(全部渠道):", "Example (specific channels):": "示例(指定渠道):", @@ -1903,7 +1912,11 @@ "Failed to update user": "更新用户失败", "Failure keywords": "失败关键词", "Fair": "公平", + "Fallback": "兜底", "Fallback base URL": "兜底 Base URL", + "Fallback for remaining models": "留空匹配剩余模型", + "Fallback must be last": "兜底必须在最后", + "Fallback route": "兜底路由", "Fallback tier": "兜底阶梯", "FAQ": "常见问答", "FAQ added. Click \"Save Settings\" to apply.": "FAQ 已添加。点击 \"保存设置\" 以应用。", @@ -1941,6 +1954,7 @@ "Fill Related Models": "填入相关模型", "Fill Template": "填入模板", "Fill Templates": "填充模板", + "Fill the complete model value from the client request body, for example gpt-4o or gemini-2.5-flash. Separate multiple models with commas.": "填写客户端请求体里的完整 model 值,例如 gpt-4o 或 gemini-2.5-flash。多个模型用英文逗号分隔。", "Fill thoughtSignature only for Gemini/Vertex channels using the OpenAI format": "仅为使用 OpenAI 格式的 Gemini/Vertex 渠道填充 thoughtSignature", "Filled {{count}} model(s)": "已填充 {{count}} 个模型", "Filled {{count}} related model(s)": "已填充 {{count}} 个关联模型", @@ -1982,6 +1996,7 @@ "First token": "首字", "First/Last Frame to Video": "首尾生视频", "Fix Abilities": "修复渠道一致性", + "Fix order": "修复顺序", "Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败", "Fixed price": "固定价格", "Fixed price (USD)": "固定价格 (USD)", @@ -2426,16 +2441,21 @@ "Leave blank to keep the existing credential": "留空以保留现有凭证", "Leave blank to keep the existing key": "留空以保留现有密钥", "Leave blank unless rotating the secret": "除非正在轮换密钥,否则留空", + "Leave empty for fallback": "留空作为兜底", "Leave empty for never expires": "留空表示永不失效", + "Leave empty only for the final fallback split.": "只有最后一个兜底分流可以留空。", "Leave empty to disable the agreement requirement. Supports Markdown, HTML, or a full URL to redirect users.": "留空以禁用协议要求。支持 Markdown、HTML 或用于重定向用户的完整 URL。", "Leave empty to disable the privacy policy requirement. Supports Markdown, HTML, or a full URL to redirect users.": "留空以禁用隐私政策要求。支持 Markdown、HTML 或用于重定向用户的完整 URL。", "Leave empty to disband the tag": "留空以解散标签", "Leave empty to keep existing key": "留空以保留现有密钥", "Leave empty to keep unchanged": "留空以保持不变", + "Leave empty to match all models": "留空匹配所有模型", "Leave empty to use account email": "留空以使用账户邮箱", "Leave empty to use default": "留空使用默认", "Leave empty to use system temp directory": "留空使用系统临时目录", "Leave empty to use username": "留空以使用用户名", + "Leave the final split empty as the fallback for models not matched above.": "将最后一个分流留空作为兜底,用于匹配前面未命中的模型。", + "Leave this empty only for the final fallback split; it catches client models not matched above.": "只有最后一个兜底分流可以留空;它会匹配前面未命中的客户端模型。", "Left to Right": "从左到右", "Legacy Format (JSON Object)": "旧格式(JSON 对象)", "Legacy format must be a JSON object": "旧格式必须是 JSON 对象", @@ -2482,6 +2502,9 @@ "Loading...": "加载中...", "Local": "本地", "Local Billing": "本地计费", + "Local Estimate (billing-usage-anthropic)": "本地估算(billing-usage-anthropic)", + "Local Estimate (billing-usage-gemini)": "本地估算(billing-usage-gemini)", + "Local Estimate (billing-usage-openai)": "本地估算(billing-usage-openai)", "Local models": "本地模型", "Locations": "位置", "Locked": "锁定", @@ -2548,7 +2571,9 @@ "Match Value": "匹配值", "Match Value (optional)": "匹配值(可选)", "Matched": "已命中", + "Matched models": "匹配模型", "Matched Tier": "命中阶梯", + "Matches models not claimed by earlier splits.": "匹配前面分流未占用的模型。", "Matching Rules": "匹配规则", "Max Disk Cache Size (MB)": "磁盘缓存最大总量 (MB)", "Max Entries": "最大条目数", @@ -2662,12 +2687,15 @@ "Model ratios reset successfully": "模型比例重置成功", "Model Regex": "模型正则", "Model Regex (one per line)": "模型正则(每行一个)", + "Model regex cannot be empty": "模型正则不能为空", + "Model scope": "模型范围", "Model selected": "已选择模型", "Model Square": "模型广场", "Model Tags": "模型标签", "Model to use for testing": "用于测试的模型", "Model to use when testing channel connectivity": "测试渠道连接时使用的模型", "Model Version *": "模型版本 *", + "Model-scoped only": "仅模型分流", "model(s) selected out of": "已选模型(共)", "model(s)? This action cannot be undone.": "模型?此操作无法撤销。", "models": "个模型", @@ -2714,9 +2742,12 @@ "Move": "移动", "Move a request header": "移动请求头", "Move affiliate rewards to your main balance": "将推广奖励转移到您的主余额", + "Move fallback to end": "兜底移到最后", "Move Field": "移动字段", "Move Header": "移动请求头", "Move Request Header": "移动请求头", + "Move route down": "下移路由", + "Move route up": "上移路由", "Move source field to target field": "把来源字段移动到目标字段", "ms": "毫秒", "Multi-key channel: Keys will be": "多密钥渠道:密钥将", @@ -3050,6 +3081,7 @@ "Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。", "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。", "Only Mine": "仅自己", + "Only one catch-all route is allowed for the same incoming path": "同一入口路径只允许一个兜底路由", "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。", "Only successful requests": "仅成功的请求", "Only successful requests count toward this limit.": "仅成功的请求计入此限制。", @@ -3088,6 +3120,7 @@ "OpenAI Rerank": "OpenAI 重排序", "OpenAI Responses": "OpenAI 响应", "OpenAI Responses Compact": "OpenAI 响应压缩", + "OpenAI Responses to Gemini Generate Content": "OpenAI Responses 转 Gemini Generate Content", "OpenAI Responses to OpenAI Chat": "OpenAI Responses 到 OpenAI Chat", "OpenAI, Anthropic, etc.": "OpenAI、Anthropic 等", "OpenAI, Anthropic, Google, etc.": "OpenAI、Anthropic、Google 等", @@ -3835,9 +3868,15 @@ "Route": "路由", "Route active": "路由已启用", "Route Description": "路由描述", + "Route group": "路由组", "Route is required": "路由为必填项", + "Route models must be unique for the same incoming path": "同一入口路径下的路由模型必须唯一", "Route, auth, and balance check in one place": "路由、认证和余额检查集中展示", "Routes": "路由", + "Routes with the same incoming path are matched by model. Leave the model scope empty only for the final fallback route.": "同一入口路径的路由按模型匹配。仅最后一个兜底路由可留空模型范围。", + "Routes with the same incoming path are split by client model rules. Unmatched requests use the final fallback.": "同一入口路径按客户端 model 规则分流;未命中的请求走最后的兜底。", + "Routes with the same incoming path are split by exact client model. Unmatched requests use the final fallback.": "同一入口路径按客户端 model 精确分流;未命中的请求走最后的兜底。", + "Routes with the same incoming path match exact client model names. Separate multiple models with commas, and leave only the final fallback empty.": "同一入口路径按客户端请求中的精确模型名匹配。多个模型用英文逗号分隔,只有最后的兜底可留空。", "Routing & Overrides": "路由与覆盖", "Routing Reliability": "路由可靠性", "Routing Strategy": "路由策略", @@ -3862,6 +3901,7 @@ "Rules": "规则", "Rules JSON": "规则 JSON", "Rules JSON must be an array": "规则 JSON 必须是数组", + "Rules match the original model value from the client request body.": "规则匹配客户端请求体里的原始 model 值。", "Run GC": "执行 GC", "Run tests for the selected models": "运行所选模型的测试", "running": "运行中", @@ -4057,6 +4097,7 @@ "Sensitive Words": "敏感词", "Sent the API key to FluentRead.": "API 密钥已发送至 FluentRead。", "Separate image/audio prices are enabled.": "已启用图像/音频单独定价。", + "Separate multiple rules with English commas. For regex patterns that need commas, switch to JSON Text.": "多个规则用英文逗号分隔。正则里需要逗号时,请切换到 JSON 文本。", "Serve multiple users or teams with billing and quota control.": "为多个用户或团队提供计费和配额管理服务。", "Server Address": "服务器地址", "Server IP": "服务器 IP", @@ -4470,6 +4511,7 @@ "This FAQ entry will be removed from the list.": "此 FAQ 条目将从列表中移除。", "This feature is experimental. Configuration format and behavior may change.": "此功能为实验性功能。配置格式和行为可能会发生变化。", "This feature requires server-side WeChat configuration": "此功能需要服务器端微信配置", + "This field does not support wildcards or regular expressions.": "这里不支持通配符或正则表达式。", "This historical record predates audit-info tracking and cannot be backfilled. The current instance already records server IP, callback IP, payment method, and system version for new top-ups going forward.": "该条历史记录缺少审计字段。当前版本已支持记录服务器 IP、回调 IP、支付方式与系统版本等审计信息;这些字段仅会写入后续新产生的记录,历史记录无法自动补齐。", "This identifier is sent to the payment backend when creating an order. Use alipay for Alipay, wxpay for WeChat Pay, stripe for Stripe. Custom values must be supported by your payment provider.": "创建订单时会把这个标识提交给支付后端。支付宝填 alipay,微信填 wxpay,Stripe 填 stripe。自定义值必须是支付服务支持的标识。", "This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.": "该实例正在使用自动主机名。请设置稳定且唯一的 NODE_NAME,以便进行多实例管理。", @@ -4536,9 +4578,13 @@ "Timing": "耗时", "Tip": "提示", "to access this resource.": "访问此资源。", + "To Anthropic Messages": "转 Anthropic Messages", "to confirm": "以确认", + "To Gemini Generate Content": "转 Gemini Generate Content", "To Lower": "转小写", "To Lowercase": "转小写", + "To OpenAI Chat": "转 OpenAI Chat", + "To OpenAI Responses": "转 OpenAI Responses", "to override billing when a user in one group uses a token of another group.": "当一个分组中的用户使用另一个分组的令牌时,用于覆盖计费。", "to the Models list so users can use them before the mapping sends traffic upstream.": "到模型列表,以便用户在映射将流量发送到上游之前可以使用它们。", "To Upper": "转大写", @@ -4786,6 +4832,12 @@ "Upstream ratios fetched successfully": "上游比率获取成功", "Upstream Request ID": "上游请求 ID", "Upstream Response": "上游返回", + "Upstream Response (billing-usage-anthropic-estimated)": "上游返回(billing-usage-anthropic-estimated)", + "Upstream Response (billing-usage-anthropic)": "上游返回(billing-usage-anthropic)", + "Upstream Response (billing-usage-gemini-estimated)": "上游返回(billing-usage-gemini-estimated)", + "Upstream Response (billing-usage-gemini)": "上游返回(billing-usage-gemini)", + "Upstream Response (billing-usage-openai-estimated)": "上游返回(billing-usage-openai-estimated)", + "Upstream Response (billing-usage-openai)": "上游返回(billing-usage-openai)", "upstream services integrated": "上游服务适配", "Upstream Updates": "上游更新", "Upstream URL": "上游 URL", @@ -4819,6 +4871,8 @@ "Use authenticator code": "使用验证器代码", "Use backup code": "使用备用代码", "Use disk cache when request body exceeds this size": "请求体超过此大小时使用磁盘缓存", + "Use exact client model names, separated by commas. Prefixes and wildcards are not supported.": "填写客户端请求里的精确 model 名,多个用英文逗号分隔。不支持前缀或通配符。", + "Use exact model names such as gpt-4o, or regex rules prefixed with re: such as re:^gemini-.": "可以填写 gpt-4o 这类精确模型名,也可以填写 re:^gemini- 这类以 re: 开头的正则规则。", "Use external tools to extend capabilities": "通过外部工具扩展能力", "Use one available reset credit for this channel. The reset request is sent only after confirmation.": "将为当前渠道使用 1 次可用重置次数。只有确认后才会发送重置请求。", "Use one available reset credit to refresh the current Codex usage windows.": "使用 1 次可用重置次数,刷新当前 Codex 用量窗口。",