Files
new-api/relay/chat_completions_via_responses_test.go
T
Calcium-Ion 0ed497f066 feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions

- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls

* refactor(relaykit): normalize reasoning and thinking intent

- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent

* fix(billing): preserve authoritative usage across relay hops

- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges

* feat(relay): bridge Responses with Claude and Gemini protocols

- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens

* fix(relay): wire relaykit conversions into host channels

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
2026-09-01 21:53:35 +08:00

156 lines
4.8 KiB
Go

package relay
import (
"io"
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
openaichannel "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/relaykit/dto"
relaytypes "github.com/QuantumNous/new-api/relaykit/types"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsResponsesEventStreamContentType(t *testing.T) {
tests := []struct {
name string
contentType string
want bool
}{
{name: "plain", contentType: "text/event-stream", want: true},
{name: "mixed case with charset", contentType: "Text/Event-Stream; charset=utf-8", want: true},
{name: "json", contentType: "application/json", want: false},
{name: "empty", contentType: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isResponsesEventStreamContentType(tt.contentType))
})
}
}
func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
info := &relaycommon.RelayInfo{
PriceData: hosttypes.PriceData{
Quota: 100,
},
}
info.PriceData.AddOtherRatio("duration", 2)
quota, ok := recalcQuotaFromRatios(info, map[string]float64{
"duration": 3,
"zero": 0,
"negative": -1,
"nan": math.NaN(),
"inf": math.Inf(1),
})
require.True(t, ok)
assert.Equal(t, 150, quota)
assert.True(t, info.PriceData.HasOtherRatio("duration"))
}
func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
info := &relaycommon.RelayInfo{
PriceData: hosttypes.PriceData{
Quota: 100,
},
}
info.PriceData.AddOtherRatio("duration", 2)
quota, ok := recalcQuotaFromRatios(info, map[string]float64{
"zero": 0,
"negative": -1,
"nan": math.NaN(),
"inf": math.Inf(1),
})
require.False(t, ok)
assert.Equal(t, 0, quota)
assert.True(t, info.PriceData.HasOtherRatio("duration"))
}
func TestTextRequestViaResponsesConvertsClaudeDirectly(t *testing.T) {
type capturedRequest struct {
path string
body []byte
}
captured := make(chan capturedRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
captured <- capturedRequest{path: r.URL.Path, body: body}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"resp_1",
"object":"response",
"status":"completed",
"model":"gpt-5.6-sol",
"output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],
"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}
}`))
}))
defer server.Close()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Content-Type", "application/json")
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeChatCompletions,
RelayFormat: relaytypes.RelayFormatClaude,
OriginModelName: "gpt-5.6-sol",
RequestConversionChain: []relaytypes.RelayFormat{relaytypes.RelayFormatClaude},
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
ChannelBaseUrl: server.URL,
ApiKey: "test-key",
UpstreamModelName: "gpt-5.6-sol",
},
}
adaptor := &openaichannel.Adaptor{}
adaptor.Init(info)
request := &dto.ClaudeRequest{
Model: "gpt-5.6-sol",
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
Messages: []dto.ClaudeMessage{{Role: "user", Content: "hello"}},
}
usage, apiErr := textRequestViaResponses(c, info, adaptor, request)
require.Nil(t, apiErr)
require.NotNil(t, usage)
assert.Equal(t, 5, usage.TotalTokens)
assert.Equal(t, []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAIResponses}, info.RequestConversionChain)
upstream := <-captured
assert.Equal(t, "/v1/responses", upstream.path)
var upstreamBody map[string]any
require.NoError(t, common.Unmarshal(upstream.body, &upstreamBody))
assert.NotContains(t, upstreamBody, "messages")
reasoning, ok := upstreamBody["reasoning"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "high", reasoning["effort"])
assert.Equal(t, "detailed", reasoning["summary"])
var response dto.ClaudeResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Len(t, response.Content, 1)
assert.Equal(t, "ok", response.Content[0].GetText())
}