feat: configurable tool pricing, Sub2API channel, and alpha search billing

Add admin-configurable tool-call prices with cross-provider surcharge
settlement, Sub2API channel support, /v1/alpha/search relay, and usage-log
surcharge UI.
This commit is contained in:
CaIon
2026-07-26 20:05:15 +08:00
parent 3e1e728279
commit 2d23cdf291
65 changed files with 3210 additions and 431 deletions
+136
View File
@@ -0,0 +1,136 @@
package relay
import (
"errors"
"fmt"
"io"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
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/types"
"github.com/gin-gonic/gin"
)
func AlphaSearchHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
switch info.ChannelType {
case constant.ChannelTypeSub2API, constant.ChannelTypeCodex, constant.ChannelTypeAdvancedCustom:
default:
// Allow retry onto another channel that may support this endpoint.
return types.NewError(
errors.New("channel does not support /v1/alpha/search"),
types.ErrorCodeInvalidRequest,
)
}
request, ok := info.Request.(*dto.AlphaSearchRequest)
if !ok {
return types.NewErrorWithStatusCode(
fmt.Errorf("invalid request type, expected *dto.AlphaSearchRequest, got %T", info.Request),
types.ErrorCodeInvalidRequest,
http.StatusBadRequest,
types.ErrOptionWithSkipRetry(),
)
}
err := helper.ModelMappedHelper(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
jsonData, err := buildAlphaSearchRequestBody(request.RawBody, info.OriginModelName, info.UpstreamModelName)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil {
return newAPIErrorFromParamOverride(err)
}
}
logger.LogDebug(c, "requestBody: %s", jsonData)
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
defer closer.Close()
info.UpstreamRequestBodySize = size
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
}
adaptor.Init(info)
resp, err := adaptor.DoRequest(c, info, body)
if err != nil {
return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
statusCodeMappingStr := c.GetString("status_code_mapping")
httpResp, ok := resp.(*http.Response)
if !ok || httpResp == nil {
return types.NewOpenAIError(errors.New("invalid http response"), types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
}
defer httpResp.Body.Close()
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError
}
if contentType := httpResp.Header.Get("Content-Type"); contentType != "" {
c.Writer.Header().Set("Content-Type", contentType)
}
c.Writer.WriteHeader(httpResp.StatusCode)
if _, err := io.Copy(c.Writer, httpResp.Body); err != nil {
return types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithSkipRetry())
}
// Upstream alpha search returns no usage; bill one web_search_preview call.
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &relaycommon.ResponsesUsageInfo{
BuiltInTools: make(map[string]*relaycommon.BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*relaycommon.BuildInToolInfo)
}
info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview] = &relaycommon.BuildInToolInfo{
ToolName: dto.BuildInToolWebSearchPreview,
CallCount: 1,
}
usage := &dto.Usage{}
service.PostTextConsumeQuota(c, info, usage, nil)
return nil
}
// buildAlphaSearchRequestBody returns RawBody unchanged unless the model was
// mapped, in which case only the "model" field is rewritten so unknown fields
// are preserved.
func buildAlphaSearchRequestBody(rawBody []byte, originModel, upstreamModel string) ([]byte, error) {
if len(rawBody) == 0 {
return nil, errors.New("empty alpha search request body")
}
if upstreamModel == "" || upstreamModel == originModel {
return rawBody, nil
}
var body map[string]any
if err := common.Unmarshal(rawBody, &body); err != nil {
return nil, err
}
body["model"] = upstreamModel
return common.Marshal(body)
}
+47
View File
@@ -0,0 +1,47 @@
package relay
import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildAlphaSearchRequestBodyPreservesUnknownFields(t *testing.T) {
raw := []byte(`{
"id":"req_1",
"model":"gpt-5.1",
"input":[{"role":"user","content":"hi"}],
"commands":{"search_query":[{"q":"weather","recency":1}]},
"settings":{"locale":"en"},
"future_field":{"nested":true}
}`)
out, err := buildAlphaSearchRequestBody(raw, "gpt-5.1", "gpt-5.1-mapped")
require.NoError(t, err)
var body map[string]any
require.NoError(t, common.Unmarshal(out, &body))
assert.Equal(t, "gpt-5.1-mapped", body["model"])
assert.Equal(t, "req_1", body["id"])
require.Contains(t, body, "commands")
require.Contains(t, body, "settings")
require.Contains(t, body, "future_field")
require.Contains(t, body, "input")
commands, ok := body["commands"].(map[string]any)
require.True(t, ok)
require.Contains(t, commands, "search_query")
future, ok := body["future_field"].(map[string]any)
require.True(t, ok)
assert.Equal(t, true, future["nested"])
}
func TestBuildAlphaSearchRequestBodyNoMappingKeepsRawBytes(t *testing.T) {
raw := []byte(`{"model":"gpt-5.1","commands":{"search_query":[{"q":"x"}]},"future_field":1}`)
out, err := buildAlphaSearchRequestBody(raw, "gpt-5.1", "gpt-5.1")
require.NoError(t, err)
assert.Equal(t, raw, out)
}
+26
View File
@@ -114,6 +114,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
data = patchClaudeMessageDeltaUsageData(data, buildMessageDeltaPatchUsage(&claudeResponse, claudeInfo))
}
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
response := StreamResponseClaude2OpenAI(&claudeResponse)
@@ -122,6 +123,8 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
countClaudeStreamBillableTools(c, info, &claudeResponse)
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
@@ -130,6 +133,23 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
}
if claudeResponse.Type == "content_block_start" &&
claudeResponse.ContentBlock != nil &&
claudeResponse.ContentBlock.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, claudeResponse.ContentBlock.Name)
}
if claudeResponse.Type == "message_delta" &&
claudeResponse.Usage != nil &&
claudeResponse.Usage.ServerToolUse != nil &&
claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}
}
func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo) {
if claudeInfo.Usage.PromptTokens == 0 {
//上游出错
@@ -235,6 +255,12 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
}
for _, block := range claudeResponse.Content {
if block.Type == "tool_use" {
info.CountBillableToolCall(dto.BuildInCallToolUse, block.Name)
}
}
service.IOCopyBytesGracefully(c, httpResp, responseData)
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package claude
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHandleClaudeResponseDataCountsToolUse(t *testing.T) {
gin.SetMode(gin.TestMode)
operation_setting.SetToolPriceForTest("lookup_fn", 3.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("lookup_fn")
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
RelayFormat: types.RelayFormatClaude,
}
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{}}
data := []byte(`{
"type":"message",
"content":[
{"type":"text","text":"hi"},
{"type":"tool_use","id":"tu1","name":"lookup_fn","input":{}},
{"type":"server_tool_use","id":"stu1","name":"web_search","input":{}}
],
"usage":{"input_tokens":1,"output_tokens":1}
}`)
err := HandleClaudeResponseData(c, info, claudeInfo, nil, data)
require.Nil(t, err)
require.NotNil(t, info.ResponsesUsageInfo)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "lookup_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["lookup_fn"].CallCount)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, "web_search")
}
func TestCountClaudeStreamBillableToolsSetsWebSearchRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
info := &relaycommon.RelayInfo{OriginModelName: "claude-3-7-sonnet"}
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
ServerToolUse: &dto.ClaudeServerToolUse{WebSearchRequests: 3},
},
})
assert.Equal(t, 3, c.GetInt("claude_web_search_requests"))
operation_setting.SetToolPriceForTest("stream_fn", 2.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("stream_fn")
})
countClaudeStreamBillableTools(c, info, &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "tool_use",
Name: "stream_fn",
},
})
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "stream_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["stream_fn"].CallCount)
}
+21 -15
View File
@@ -112,18 +112,20 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact {
switch info.RelayMode {
case relayconstant.RelayModeAlphaSearch:
// Alpha search responses are handled by relay.AlphaSearchHelper.
return nil, types.NewError(errors.New("codex channel: alpha search response should be handled by AlphaSearchHelper"), types.ErrorCodeInvalidRequest)
case relayconstant.RelayModeResponsesCompact:
return openai.OaiResponsesCompactionHandler(c, resp)
case relayconstant.RelayModeResponses:
if info.IsStream {
return openai.OaiResponsesStreamHandler(c, info, resp)
}
return openai.OaiResponsesHandler(c, info, resp)
default:
return nil, types.NewError(errors.New("codex channel: endpoint not supported"), types.ErrorCodeInvalidRequest)
}
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
return openai.OaiResponsesCompactionHandler(c, resp)
}
if info.IsStream {
return openai.OaiResponsesStreamHandler(c, info, resp)
}
return openai.OaiResponsesHandler(c, info, resp)
}
func (a *Adaptor) GetModelList() []string {
@@ -135,12 +137,16 @@ func (a *Adaptor) GetChannelName() string {
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact {
return "", errors.New("codex channel: only /v1/responses and /v1/responses/compact are supported")
}
path := "/backend-api/codex/responses"
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
var path string
switch info.RelayMode {
case relayconstant.RelayModeResponses:
path = "/backend-api/codex/responses"
case relayconstant.RelayModeResponsesCompact:
path = "/backend-api/codex/responses/compact"
case relayconstant.RelayModeAlphaSearch:
path = "/backend-api/codex/alpha/search"
default:
return "", errors.New("codex channel: only /v1/responses, /v1/responses/compact and /v1/alpha/search are supported")
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil
}
+26
View File
@@ -0,0 +1,26 @@
package codex
import (
"testing"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRequestURLAlphaSearch(t *testing.T) {
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeCodex,
ChannelBaseUrl: "https://chatgpt.com",
},
RelayMode: relayconstant.RelayModeAlphaSearch,
}
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://chatgpt.com/backend-api/codex/alpha/search", url)
}
+15
View File
@@ -76,6 +76,18 @@ func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
return text.String()
}
func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse) {
if c == nil || response == nil {
return
}
for _, candidate := range response.Candidates {
if candidate.GroundingMetadata != nil && len(candidate.GroundingMetadata.WebSearchQueries) > 0 {
c.Set("gemini_google_search_call", true)
return
}
}
}
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
metadata := response.GetUsageMetadata()
if dto.HasGeminiUsageMetadataTokens(metadata) {
@@ -149,6 +161,8 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
}
markGeminiGoogleSearchCall(c, &geminiResponse)
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
for _, part := range candidate.Content.Parts {
@@ -308,6 +322,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
+1
View File
@@ -31,6 +31,7 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
if err := common.Unmarshal(responseBody, &geminiResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
+38
View File
@@ -119,6 +119,8 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var usage = &dto.Usage{}
var lastStreamData string
var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型
seenStreamToolCalls := make(map[string]struct{})
var streamFunctionCallNames []string
// 检查是否为音频模型
isAudioModel := strings.Contains(strings.ToLower(model), "audio")
@@ -137,6 +139,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
lastStreamData = data
collectStreamFunctionCallNames(data, seenStreamToolCalls, &streamFunctionCallNames)
if err := processTokenData(info.RelayMode, data, &responseTextBuilder, &toolCount); err != nil {
logger.LogError(c, "error processing stream token data: "+err.Error())
sr.Error(err)
@@ -182,11 +185,40 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData))
for _, name := range streamFunctionCallNames {
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
}
HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage)
return usage, nil
}
func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names *[]string) {
var streamResponse dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
return
}
for _, choice := range streamResponse.Choices {
for i, tc := range choice.Delta.ToolCalls {
name := tc.Function.Name
if name == "" {
continue
}
toolIdx := i
if tc.Index != nil {
toolIdx = *tc.Index
}
key := fmt.Sprintf("%d-%d", choice.Index, toolIdx)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
*names = append(*names, name)
}
}
}
func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
defer service.CloseResponseBodyGracefully(resp)
@@ -228,6 +260,12 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
}
}
for _, choice := range simpleResponse.Choices {
for _, tc := range choice.Message.ParseToolCalls() {
info.CountBillableToolCall(dto.BuildInCallFunctionCall, tc.Function.Name)
}
}
forceFormat := false
if info.ChannelSetting.ForceFormat {
forceFormat = true
+52 -26
View File
@@ -34,12 +34,6 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
if responsesResponse.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", responsesResponse.GetQuality())
c.Set("image_generation_call_size", responsesResponse.GetSize())
}
// 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody)
@@ -54,18 +48,27 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
}
}
if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil {
return &usage, nil
}
// 解析 Tools 用量
for _, tool := range responsesResponse.Tools {
buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])]
if !ok || buildToolinfo == nil {
logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"]))
continue
// Count actual tool invocations from Output (not tool declarations).
for _, output := range responsesResponse.Output {
switch output.Type {
case dto.BuildInCallWebSearchCall:
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
case dto.BuildInCallFileSearchCall:
info.CountBillableToolCall(dto.BuildInCallFileSearchCall, "")
case dto.BuildInCallFunctionCall:
info.CountBillableToolCall(dto.BuildInCallFunctionCall, output.Name)
}
buildToolinfo.CallCount++
}
imageCounter := &relaycommon.ImageGenerationCallCounter{}
if !relaycommon.IsNonBillableResponsesStatus(responsesResponse.Status) {
for i := range responsesResponse.Output {
idx := i
imageCounter.Observe(&responsesResponse.Output[i], &idx)
}
}
imageCounter.Commit(info)
return &usage, nil
}
@@ -79,6 +82,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
var usage = &dto.Usage{}
var responseTextBuilder strings.Builder
imageCounter := &relaycommon.ImageGenerationCallCounter{}
imageCommitted := false
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
@@ -91,7 +96,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type {
case "response.completed":
case "response.completed", "response.done":
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
@@ -108,24 +113,45 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
}
}
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
c.Set("image_generation_call_size", streamResponse.Response.GetSize())
if !imageCommitted {
if relaycommon.IsNonBillableResponsesStatus(streamResponse.Response.Status) {
imageCounter.Reset()
imageCounter.Commit(info)
imageCommitted = true
} else {
for i := range streamResponse.Response.Output {
idx := i
imageCounter.Observe(&streamResponse.Response.Output[i], &idx)
}
imageCounter.Commit(info)
imageCommitted = true
}
}
} else if !imageCommitted {
imageCounter.Commit(info)
imageCommitted = true
}
case "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
if !imageCommitted {
imageCounter.Reset()
imageCounter.Commit(info)
imageCommitted = true
}
case "response.output_text.delta":
// 处理输出文本
responseTextBuilder.WriteString(streamResponse.Delta)
case dto.ResponsesOutputTypeItemDone:
// 函数调用处理
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil {
if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil {
webSearchTool.CallCount++
}
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
case dto.BuildInCallFileSearchCall:
info.CountBillableToolCall(dto.BuildInCallFileSearchCall, "")
case dto.BuildInCallFunctionCall:
info.CountBillableToolCall(dto.BuildInCallFunctionCall, streamResponse.Item.Name)
case dto.ResponsesOutputTypeImageGenerationCall:
if !imageCommitted {
imageCounter.Observe(streamResponse.Item, streamResponse.OutputIndex)
}
}
}
@@ -0,0 +1,267 @@
package openai
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"strings"
"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"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOaiResponsesHandlerCountsOutputCallsNotDeclarations(t *testing.T) {
gin.SetMode(gin.TestMode)
operation_setting.SetToolPriceForTest("priced_fn", 5.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("priced_fn")
})
body, err := common.Marshal(dto.OpenAIResponsesResponse{
Tools: []map[string]any{
{"type": "web_search_preview"},
{"type": "file_search"},
},
Output: []dto.ResponsesOutput{
{Type: dto.BuildInCallWebSearchCall},
{Type: dto.BuildInCallWebSearchCall},
{Type: dto.BuildInCallFunctionCall, Name: "priced_fn"},
{Type: dto.BuildInCallFunctionCall, Name: "unpriced_fn"},
},
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
})
require.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1",
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
dto.BuildInToolWebSearchPreview: {ToolName: dto.BuildInToolWebSearchPreview, CallCount: 0},
dto.BuildInToolFileSearch: {ToolName: dto.BuildInToolFileSearch, CallCount: 0},
},
},
}
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
usage, apiErr := OaiResponsesHandler(c, info, resp)
require.Nil(t, apiErr)
require.NotNil(t, usage)
assert.Equal(t, 2, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch].CallCount)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "priced_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["priced_fn"].CallCount)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, "unpriced_fn")
}
func TestOaiResponsesHandlerDeclaredToolsWithoutOutputCountZero(t *testing.T) {
gin.SetMode(gin.TestMode)
body, err := common.Marshal(dto.OpenAIResponsesResponse{
Tools: []map[string]any{
{"type": "web_search_preview"},
{"type": "file_search"},
},
Output: []dto.ResponsesOutput{
{Type: "message", Role: "assistant"},
},
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
})
require.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1",
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
dto.BuildInToolWebSearchPreview: {ToolName: dto.BuildInToolWebSearchPreview, CallCount: 0},
dto.BuildInToolFileSearch: {ToolName: dto.BuildInToolFileSearch, CallCount: 0},
},
},
}
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
_, apiErr := OaiResponsesHandler(c, info, resp)
require.Nil(t, apiErr)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch].CallCount)
}
func TestOaiResponsesHandlerCountsCompletedImageGenerationOutputs(t *testing.T) {
gin.SetMode(gin.TestMode)
body, err := common.Marshal(dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: []dto.ResponsesOutput{
{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "completed",
Result: "base64-a",
},
{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_2",
Status: "completed",
Result: "base64-b",
},
{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_empty",
Status: "completed",
Result: "",
},
},
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
})
require.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
info := &relaycommon.RelayInfo{OriginModelName: "gpt-5.1"}
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
_, apiErr := OaiResponsesHandler(c, info, resp)
require.Nil(t, apiErr)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
assert.Equal(t, 2, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
assert.False(t, c.GetBool("image_generation_call"))
}
func TestOaiResponsesHandlerIncompleteStatusCommitsZeroImageGeneration(t *testing.T) {
gin.SetMode(gin.TestMode)
body, err := common.Marshal(dto.OpenAIResponsesResponse{
Status: []byte(`"incomplete"`),
Output: []dto.ResponsesOutput{
{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "completed",
Result: "base64-a",
},
},
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
})
require.NoError(t, err)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1",
ResponsesUsageInfo: &relaycommon.ResponsesUsageInfo{
BuiltInTools: map[string]*relaycommon.BuildInToolInfo{
dto.BuildInToolImageGeneration: {ToolName: dto.BuildInToolImageGeneration, CallCount: 0},
},
},
}
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
_, apiErr := OaiResponsesHandler(c, info, resp)
require.Nil(t, apiErr)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func runResponsesImageBillingStream(t *testing.T, events ...string) *relaycommon.RelayInfo {
t.Helper()
gin.SetMode(gin.TestMode)
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() {
constant.StreamingTimeout = oldTimeout
})
var body strings.Builder
for _, event := range events {
body.WriteString("data: ")
body.WriteString(event)
body.WriteString("\n\n")
}
body.WriteString("data: [DONE]\n\n")
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(common.RequestIdKey, "responses-image-billing-test")
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1",
DisablePing: true,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gpt-5.1",
},
}
resp := &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body.String())),
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
}
_, apiErr := OaiResponsesStreamHandler(c, info, resp)
require.Nil(t, apiErr)
require.NotNil(t, info.ResponsesUsageInfo)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
return info
}
func TestOaiResponsesStreamHandlerDeduplicatesCompletedImageOutput(t *testing.T) {
item := `{"type":"image_generation_call","id":"img_1","call_id":"call_1","status":"completed","result":"base64-a"}`
info := runResponsesImageBillingStream(
t,
`{"type":"response.output_item.done","output_index":0,"item":`+item+`}`,
`{"type":"response.completed","response":{"status":"completed","output":[`+item+`],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`,
)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestOaiResponsesStreamHandlerDiscardsImageOutputOnIncomplete(t *testing.T) {
info := runResponsesImageBillingStream(
t,
`{"type":"response.output_item.done","output_index":0,"item":{"type":"image_generation_call","id":"img_1","status":"completed","result":"base64-a"}}`,
`{"type":"response.incomplete","response":{"status":"incomplete"}}`,
)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestOaiResponsesStreamHandlerDoesNotCountPartialImageEvent(t *testing.T) {
info := runResponsesImageBillingStream(
t,
`{"type":"response.image_generation_call.partial_image","output_index":0,"partial_image_b64":"partial-bytes"}`,
`{"type":"response.completed","response":{"status":"completed","output":[]}}`,
)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
@@ -0,0 +1,27 @@
package openai
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCollectStreamFunctionCallNamesDedupesSameIndex(t *testing.T) {
seen := make(map[string]struct{})
var names []string
chunks := []string{
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"q\":"}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"x\"}"}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"c2","type":"function","function":{"name":"get_time","arguments":""}}]}}]}`,
`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{}"}}]}}]}`,
}
for _, chunk := range chunks {
collectStreamFunctionCallNames(chunk, seen, &names)
}
require.Len(t, names, 2)
assert.Equal(t, []string{"get_weather", "get_time"}, names)
}
+121
View File
@@ -0,0 +1,121 @@
package sub2api
import (
"errors"
"io"
"net/http"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/claude"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type Adaptor struct {
openaiAdaptor openai.Adaptor
claudeAdaptor claude.Adaptor
geminiAdaptor gemini.Adaptor
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
a.openaiAdaptor.Init(info)
a.claudeAdaptor.Init(info)
a.geminiAdaptor.Init(info)
}
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode == relayconstant.RelayModeAlphaSearch {
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, "/v1/alpha/search", info.ChannelType), nil
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)
req.Set("Authorization", "Bearer "+info.ApiKey)
switch info.RelayFormat {
case types.RelayFormatClaude:
req.Set("x-api-key", info.ApiKey)
if req.Get("anthropic-version") == "" {
anthropicVersion := c.Request.Header.Get("anthropic-version")
if anthropicVersion == "" {
anthropicVersion = "2023-06-01"
}
req.Set("anthropic-version", anthropicVersion)
}
case types.RelayFormatGemini:
req.Set("x-goog-api-key", info.ApiKey)
}
return nil
}
func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
return request, nil
}
func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) {
return request, nil
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
return request, nil
}
func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) {
return nil, errors.New("endpoint not supported")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
return nil, errors.New("endpoint not supported")
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
return channel.DoApiRequest(a, c, info, requestBody)
}
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
switch info.RelayFormat {
case types.RelayFormatClaude:
return a.claudeAdaptor.DoResponse(c, resp, info)
case types.RelayFormatGemini:
return a.geminiAdaptor.DoResponse(c, resp, info)
default:
return a.openaiAdaptor.DoResponse(c, resp, info)
}
}
func (a *Adaptor) GetModelList() []string {
return ModelList
}
func (a *Adaptor) GetChannelName() string {
return ChannelName
}
+27
View File
@@ -0,0 +1,27 @@
package sub2api
import (
"testing"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRequestURLAlphaSearch(t *testing.T) {
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeSub2API,
ChannelBaseUrl: "https://sub2api.example",
},
RequestURLPath: "/v1/alpha/search",
RelayMode: relayconstant.RelayModeAlphaSearch,
}
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://sub2api.example/v1/alpha/search", url)
}
+6
View File
@@ -0,0 +1,6 @@
package sub2api
const ChannelName = "sub2api"
// ModelList is empty because models are fetched dynamically from upstream /v1/models.
var ModelList = []string{}
+23
View File
@@ -342,6 +342,7 @@ var streamSupportedChannels = map[int]bool{
constant.ChannelTypeMiniMax: true,
constant.ChannelTypeSiliconFlow: true,
constant.ChannelTypeAdvancedCustom: true,
constant.ChannelTypeSub2API: true,
constant.ChannelTypeTencent: true,
}
@@ -576,6 +577,11 @@ func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Req
return GenRelayInfoResponsesCompaction(c, request), nil
}
return nil, errors.New("request is not a OpenAIResponsesCompactionRequest")
case types.RelayFormatOpenAIAlphaSearch:
if request, ok := request.(*dto.AlphaSearchRequest); ok {
return GenRelayInfoAlphaSearch(c, request), nil
}
return nil, errors.New("request is not a AlphaSearchRequest")
case types.RelayFormatTask:
info = genBaseRelayInfo(c, nil)
info.TaskRelayInfo = &TaskRelayInfo{}
@@ -650,6 +656,23 @@ func GenRelayInfoResponsesCompaction(c *gin.Context, request *dto.OpenAIResponse
return info
}
func GenRelayInfoAlphaSearch(c *gin.Context, request *dto.AlphaSearchRequest) *RelayInfo {
info := genBaseRelayInfo(c, request)
if info.RelayMode == relayconstant.RelayModeUnknown {
info.RelayMode = relayconstant.RelayModeAlphaSearch
}
info.RelayFormat = types.RelayFormatOpenAIAlphaSearch
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: map[string]*BuildInToolInfo{
dto.BuildInToolWebSearchPreview: {
ToolName: dto.BuildInToolWebSearchPreview,
CallCount: 0,
},
},
}
return info
}
//func (info *RelayInfo) SetPromptTokens(promptTokens int) {
// info.promptTokens = promptTokens
//}
+194
View File
@@ -0,0 +1,194 @@
package common
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/setting/operation_setting"
)
var reservedBillableToolNames = map[string]struct{}{
dto.BuildInToolWebSearchPreview: {},
dto.BuildInToolWebSearch: {},
dto.BuildInToolFileSearch: {},
dto.BuildInToolGoogleSearch: {},
dto.BuildInToolImageGeneration: {},
}
// CountBillableToolCall is the single entry point for per-call tool billing counts.
// Built-in call types always count; custom function/tool_use names only count when priced.
func (info *RelayInfo) CountBillableToolCall(itemType string, functionName string) {
if info == nil {
return
}
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
switch itemType {
case dto.BuildInCallWebSearchCall:
info.incrementBillableToolCall(resolveWebSearchToolName(info.ResponsesUsageInfo.BuiltInTools))
case dto.BuildInCallFileSearchCall:
info.incrementBillableToolCall(dto.BuildInToolFileSearch)
case dto.BuildInCallFunctionCall, dto.BuildInCallToolUse:
if functionName == "" {
return
}
if _, reserved := reservedBillableToolNames[functionName]; reserved {
return
}
if operation_setting.GetToolPriceForModel(functionName, info.OriginModelName) <= 0 {
return
}
info.incrementBillableToolCall(functionName)
}
}
func resolveWebSearchToolName(tools map[string]*BuildInToolInfo) string {
if _, ok := tools[dto.BuildInToolWebSearchPreview]; ok {
return dto.BuildInToolWebSearchPreview
}
if _, ok := tools[dto.BuildInToolWebSearch]; ok {
return dto.BuildInToolWebSearch
}
return dto.BuildInToolWebSearchPreview
}
func (info *RelayInfo) incrementBillableToolCall(name string) {
if existing, ok := info.ResponsesUsageInfo.BuiltInTools[name]; ok && existing != nil {
existing.CallCount++
return
}
info.ResponsesUsageInfo.BuiltInTools[name] = &BuildInToolInfo{
ToolName: name,
CallCount: 1,
}
}
// ImageGenerationCallCounter counts completed Responses image_generation_call
// outputs with stream-safe identity deduplication.
type ImageGenerationCallCounter struct {
seen map[string]struct{}
count int
}
// Observe records one completed final image output when billable.
// outputIndex may be nil; when set and nonnegative it participates in dedup.
func (c *ImageGenerationCallCounter) Observe(item *dto.ResponsesOutput, outputIndex *int) {
if c == nil || item == nil {
return
}
if item.Type != dto.ResponsesOutputTypeImageGenerationCall {
return
}
if strings.TrimSpace(item.Result) == "" {
return
}
switch strings.ToLower(strings.TrimSpace(item.Status)) {
case "failed", "cancelled", "canceled", "incomplete", "partial":
return
}
aliases := make([]string, 0, 4)
if item.ID != "" {
aliases = append(aliases, "id:"+item.ID)
}
if item.CallId != "" {
aliases = append(aliases, "call:"+item.CallId)
}
if outputIndex != nil && *outputIndex >= 0 {
aliases = append(aliases, fmt.Sprintf("index:%d", *outputIndex))
}
sum := sha256.Sum256([]byte(item.Result))
aliases = append(aliases, "result:"+hex.EncodeToString(sum[:]))
if c.seen == nil {
c.seen = make(map[string]struct{})
}
for _, alias := range aliases {
if _, ok := c.seen[alias]; ok {
return
}
}
for _, alias := range aliases {
c.seen[alias] = struct{}{}
}
c.count++
}
// Reset clears pending observations (used when a terminal response fails).
func (c *ImageGenerationCallCounter) Reset() {
if c == nil {
return
}
c.seen = nil
c.count = 0
}
// Count returns the deduplicated completed image output count before commit capping.
func (c *ImageGenerationCallCounter) Count() int {
if c == nil {
return 0
}
return c.count
}
// Commit writes the capped completed-output count into RelayInfo once.
// Request tool declarations alone must not become billable calls.
func (c *ImageGenerationCallCounter) Commit(info *RelayInfo) {
if info == nil {
return
}
if info.ResponsesUsageInfo == nil {
info.ResponsesUsageInfo = &ResponsesUsageInfo{
BuiltInTools: make(map[string]*BuildInToolInfo),
}
}
if info.ResponsesUsageInfo.BuiltInTools == nil {
info.ResponsesUsageInfo.BuiltInTools = make(map[string]*BuildInToolInfo)
}
count := 0
if c != nil {
count = c.count
}
if count > dto.MaxImageN {
count = dto.MaxImageN
}
if existing, ok := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration]; ok && existing != nil {
existing.CallCount = count
return
}
info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration] = &BuildInToolInfo{
ToolName: dto.BuildInToolImageGeneration,
CallCount: count,
}
}
// IsNonBillableResponsesStatus reports terminal response statuses that must not
// bill pending image_generation observations.
func IsNonBillableResponsesStatus(status []byte) bool {
if len(status) == 0 {
return false
}
var s string
if err := common.Unmarshal(status, &s); err != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(s)) {
case "failed", "cancelled", "canceled", "incomplete":
return true
default:
return false
}
}
+348
View File
@@ -0,0 +1,348 @@
package common
import (
"strings"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCountBillableToolCallWebSearchPrefersDeclaredWebSearch(t *testing.T) {
info := &RelayInfo{
OriginModelName: "gpt-5.1",
ResponsesUsageInfo: &ResponsesUsageInfo{
BuiltInTools: map[string]*BuildInToolInfo{
dto.BuildInToolWebSearch: {ToolName: dto.BuildInToolWebSearch, CallCount: 0},
},
},
}
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolWebSearch)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearch].CallCount)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolWebSearchPreview)
}
func TestCountBillableToolCallWebSearchDefaultsToPreview(t *testing.T) {
info := &RelayInfo{OriginModelName: "gpt-5.1"}
info.CountBillableToolCall(dto.BuildInCallWebSearchCall, "")
require.NotNil(t, info.ResponsesUsageInfo)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolWebSearchPreview)
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount)
}
func TestCountBillableToolCallFunctionCallRequiresPrice(t *testing.T) {
operation_setting.SetToolPriceForTest("my_priced_fn", 5.0)
t.Cleanup(func() {
operation_setting.DeleteToolPriceForTest("my_priced_fn")
})
info := &RelayInfo{OriginModelName: "gpt-5.1"}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, "my_priced_fn")
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, "my_priced_fn")
assert.Equal(t, 1, info.ResponsesUsageInfo.BuiltInTools["my_priced_fn"].CallCount)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, "unpriced_fn")
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, "unpriced_fn")
}
func TestCountBillableToolCallFunctionCallSkipsReservedNames(t *testing.T) {
info := &RelayInfo{OriginModelName: "gpt-5.1"}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolWebSearchPreview)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolFileSearch)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolGoogleSearch)
info.CountBillableToolCall(dto.BuildInCallFunctionCall, dto.BuildInToolImageGeneration)
if info.ResponsesUsageInfo != nil {
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolWebSearchPreview)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolFileSearch)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolGoogleSearch)
assert.NotContains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
}
}
func TestImageGenerationCallCounterCompletedOutputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
observe func(c *ImageGenerationCallCounter)
wantCount int
}{
{
name: "one final result",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "completed",
Result: "base64-a",
}, &idx)
},
wantCount: 1,
},
{
name: "two distinct finals",
observe: func(c *ImageGenerationCallCounter) {
idx0, idx1 := 0, 1
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Result: "base64-a",
}, &idx0)
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_2",
Result: "base64-b",
}, &idx1)
},
wantCount: 2,
},
{
name: "empty result",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Result: " ",
}, &idx)
},
wantCount: 0,
},
{
name: "failed status",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "failed",
Result: "base64-a",
}, &idx)
},
wantCount: 0,
},
{
name: "incomplete status",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "incomplete",
Result: "base64-a",
}, &idx)
},
wantCount: 0,
},
{
name: "cancelled status",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "cancelled",
Result: "base64-a",
}, &idx)
},
wantCount: 0,
},
{
name: "canceled status",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "canceled",
Result: "base64-a",
}, &idx)
},
wantCount: 0,
},
{
name: "partial status",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "partial",
Result: "partial-bytes",
}, &idx)
},
wantCount: 0,
},
{
name: "id dedup",
observe: func(c *ImageGenerationCallCounter) {
idx0, idx1 := 0, 1
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
CallId: "call_a",
Result: "base64-a",
}, &idx0)
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
CallId: "call_b",
Result: "base64-b",
}, &idx1)
},
wantCount: 1,
},
{
name: "index dedup",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Result: "base64-a",
}, &idx)
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_2",
Result: "base64-b",
}, &idx)
},
wantCount: 1,
},
{
name: "result hash dedup",
observe: func(c *ImageGenerationCallCounter) {
idx0, idx1 := 0, 1
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
Result: "same-bytes",
}, &idx0)
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
Result: "same-bytes",
}, &idx1)
},
wantCount: 1,
},
{
name: "output_item.done plus completed dedup",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
item := &dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
CallId: "call_1",
Status: "completed",
Result: "base64-a",
}
c.Observe(item, &idx)
c.Observe(item, &idx)
},
wantCount: 1,
},
{
name: "output_item.done plus incomplete equals zero",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "completed",
Result: "base64-a",
}, &idx)
c.Reset()
},
wantCount: 0,
},
{
name: "partial event equals zero",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: "image_generation_call.partial_image",
ID: "img_1",
Result: "partial-bytes",
}, &idx)
},
wantCount: 0,
},
{
name: "in_progress with final result counts",
observe: func(c *ImageGenerationCallCounter) {
idx := 0
c.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_1",
Status: "in_progress",
Result: "base64-a",
}, &idx)
},
wantCount: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
counter := &ImageGenerationCallCounter{}
tt.observe(counter)
assert.Equal(t, tt.wantCount, counter.Count())
})
}
}
func TestImageGenerationCallCounterCommitCapsAtMaxImageN(t *testing.T) {
t.Parallel()
counter := &ImageGenerationCallCounter{}
for i := 0; i < dto.MaxImageN+3; i++ {
idx := i
counter.Observe(&dto.ResponsesOutput{
Type: dto.ResponsesOutputTypeImageGenerationCall,
ID: "img_" + strings.Repeat("a", i+1),
Result: "result-" + strings.Repeat("b", i+1),
}, &idx)
}
require.Equal(t, dto.MaxImageN+3, counter.Count())
info := &RelayInfo{}
counter.Commit(info)
require.Contains(t, info.ResponsesUsageInfo.BuiltInTools, dto.BuildInToolImageGeneration)
assert.Equal(t, dto.MaxImageN, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestImageGenerationCallCounterCommitDoesNotBillDeclarationsAlone(t *testing.T) {
t.Parallel()
info := &RelayInfo{
ResponsesUsageInfo: &ResponsesUsageInfo{
BuiltInTools: map[string]*BuildInToolInfo{
dto.BuildInToolImageGeneration: {
ToolName: dto.BuildInToolImageGeneration,
CallCount: 0,
},
},
},
}
(&ImageGenerationCallCounter{}).Commit(info)
assert.Equal(t, 0, info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolImageGeneration].CallCount)
}
func TestIsNonBillableResponsesStatus(t *testing.T) {
t.Parallel()
assert.True(t, IsNonBillableResponsesStatus([]byte(`"failed"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"incomplete"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"cancelled"`)))
assert.True(t, IsNonBillableResponsesStatus([]byte(`"canceled"`)))
assert.False(t, IsNonBillableResponsesStatus([]byte(`"completed"`)))
assert.False(t, IsNonBillableResponsesStatus(nil))
}
+4
View File
@@ -52,6 +52,8 @@ const (
RelayModeGemini
RelayModeResponsesCompact
RelayModeAlphaSearch
)
func Path2RelayMode(path string) int {
@@ -76,6 +78,8 @@ func Path2RelayMode(path string) int {
relayMode = RelayModeResponsesCompact
} else if strings.HasPrefix(path, "/v1/responses") {
relayMode = RelayModeResponses
} else if strings.HasPrefix(path, "/v1/alpha/search") {
relayMode = RelayModeAlphaSearch
} else if strings.HasPrefix(path, "/v1/audio/speech") {
relayMode = RelayModeAudioSpeech
} else if strings.HasPrefix(path, "/v1/audio/transcriptions") {
+22
View File
@@ -0,0 +1,22 @@
package constant
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPath2RelayMode(t *testing.T) {
tests := []struct {
path string
want int
}{
{path: "/v1/alpha/search", want: RelayModeAlphaSearch},
{path: "/v1/alpha/search?foo=1", want: RelayModeAlphaSearch},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
assert.Equal(t, tt.want, Path2RelayMode(tt.path))
})
}
}
+22
View File
@@ -38,6 +38,8 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt
request, err = GetAndValidateResponsesRequest(c)
case types.RelayFormatOpenAIResponsesCompaction:
request, err = GetAndValidateResponsesCompactionRequest(c)
case types.RelayFormatOpenAIAlphaSearch:
request, err = GetAndValidateAlphaSearchRequest(c)
case types.RelayFormatOpenAIImage:
request, err = GetAndValidOpenAIImageRequest(c, relayMode)
@@ -146,6 +148,26 @@ func GetAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest
return request, nil
}
func GetAndValidateAlphaSearchRequest(c *gin.Context) (*dto.AlphaSearchRequest, error) {
request := &dto.AlphaSearchRequest{}
if err := common.UnmarshalBodyReusable(c, request); err != nil {
return nil, err
}
if request.Model == "" {
return nil, errors.New("model is required")
}
storage, err := common.GetBodyStorage(c)
if err != nil {
return nil, err
}
rawBody, err := storage.Bytes()
if err != nil {
return nil, err
}
request.RawBody = rawBody
return request, nil
}
func GetAndValidateResponsesCompactionRequest(c *gin.Context) (*dto.OpenAIResponsesCompactionRequest, error) {
request := &dto.OpenAIResponsesCompactionRequest{}
if err := common.UnmarshalBodyReusable(c, request); err != nil {
+3
View File
@@ -30,6 +30,7 @@ import (
"github.com/QuantumNous/new-api/relay/channel/perplexity"
"github.com/QuantumNous/new-api/relay/channel/replicate"
"github.com/QuantumNous/new-api/relay/channel/siliconflow"
"github.com/QuantumNous/new-api/relay/channel/sub2api"
"github.com/QuantumNous/new-api/relay/channel/submodel"
taskali "github.com/QuantumNous/new-api/relay/channel/task/ali"
taskdoubao "github.com/QuantumNous/new-api/relay/channel/task/doubao"
@@ -123,6 +124,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &codex.Adaptor{}
case constant.APITypeAdvancedCustom:
return &advancedcustom.Adaptor{}
case constant.APITypeSub2API:
return &sub2api.Adaptor{}
}
return nil
}