feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
This commit is contained in:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,24 @@
|
||||
package common
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// BillingSettler 抽象计费会话的生命周期操作。
|
||||
// 由 service.BillingSession 实现,存储在 RelayInfo 上以避免循环引用。
|
||||
type BillingSettler interface {
|
||||
// Settle 根据实际消耗额度进行结算,计算 delta = actualQuota - preConsumedQuota,
|
||||
// 同时调整资金来源(钱包/订阅)和令牌额度。
|
||||
Settle(actualQuota int) error
|
||||
|
||||
// Refund 退还所有预扣费额度(资金来源 + 令牌),幂等安全。
|
||||
// 通过 gopool 异步执行。如果已经结算或退款则不做任何操作。
|
||||
Refund(c *gin.Context)
|
||||
|
||||
// NeedsRefund 返回会话是否存在需要退还的预扣状态(未结算且未退款)。
|
||||
NeedsRefund() bool
|
||||
|
||||
// GetPreConsumedQuota 返回实际预扣的额度值(信任用户可能为 0)。
|
||||
GetPreConsumedQuota() int
|
||||
|
||||
// Reserve 将预扣额度补到目标值;若目标值不高于当前预扣额度则不做任何事。
|
||||
Reserve(targetQuota int) error
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxConversionDiagnostics = 32
|
||||
|
||||
type conversionDiagnosticKey struct {
|
||||
code string
|
||||
path string
|
||||
severity types.ConversionDiagnosticSeverity
|
||||
from types.RelayFormat
|
||||
to types.RelayFormat
|
||||
}
|
||||
|
||||
// RecordConversionDiagnostics retains conversion losses for the consume log
|
||||
// and emits one request-correlated warning per distinct diagnostic. The cap
|
||||
// prevents malformed streams from growing request state without bound.
|
||||
func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnostics []types.ConversionDiagnostic) {
|
||||
if info == nil || len(diagnostics) == 0 {
|
||||
return
|
||||
}
|
||||
if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx == nil {
|
||||
ctx = nil
|
||||
}
|
||||
if info.conversionDiagnosticKeys == nil {
|
||||
info.conversionDiagnosticKeys = make(map[conversionDiagnosticKey]struct{})
|
||||
}
|
||||
for _, diagnostic := range diagnostics {
|
||||
key := conversionDiagnosticKey{
|
||||
code: diagnostic.Code,
|
||||
path: diagnostic.Path,
|
||||
severity: diagnostic.Severity,
|
||||
from: diagnostic.From,
|
||||
to: diagnostic.To,
|
||||
}
|
||||
if _, exists := info.conversionDiagnosticKeys[key]; exists {
|
||||
continue
|
||||
}
|
||||
if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
|
||||
if !info.conversionDiagnosticsTruncated {
|
||||
info.conversionDiagnosticsTruncated = true
|
||||
logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
|
||||
}
|
||||
continue
|
||||
}
|
||||
info.conversionDiagnosticKeys[key] = struct{}{}
|
||||
info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
|
||||
logger.LogWarn(ctx, fmt.Sprintf(
|
||||
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q",
|
||||
diagnostic.Code, diagnostic.Severity, diagnostic.From, diagnostic.To, diagnostic.Path, diagnostic.Message,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
func (info *RelayInfo) ConversionDiagnostics() []types.ConversionDiagnostic {
|
||||
if info == nil || len(info.conversionDiagnostics) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]types.ConversionDiagnostic(nil), info.conversionDiagnostics...)
|
||||
}
|
||||
|
||||
func (info *RelayInfo) ConversionDiagnosticsTruncated() bool {
|
||||
return info != nil && info.conversionDiagnosticsTruncated
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// NewOutboundJSONBody wraps the already-marshaled upstream request body into a
|
||||
// BodyStorage. When disk cache is enabled and the payload exceeds the configured
|
||||
// threshold, the data is written to a temp file and the original []byte can be
|
||||
// GC'd, significantly reducing the heap residency while waiting for the
|
||||
// upstream provider to respond (the dominant cost for large base64 payloads).
|
||||
//
|
||||
// In memory mode the underlying memoryStorage reuses the same backing array,
|
||||
// so this is equivalent to bytes.NewReader(data) in terms of memory usage.
|
||||
//
|
||||
// The caller MUST invoke closer.Close() once the upstream call has finished
|
||||
// (typically via defer) to release the disk file / memory accounting.
|
||||
//
|
||||
// The returned body exposes its size and replay capability without exposing
|
||||
// io.Closer. Request construction uses that metadata to populate ContentLength
|
||||
// and GetBody, while the caller retains ownership of the underlying storage
|
||||
// through the separately returned closer.
|
||||
func NewOutboundJSONBody(data []byte) (body common.ReplayableBody, closer io.Closer, err error) {
|
||||
storage, err := common.CreateBodyStorage(data)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return common.NewReplayableBodyReader(storage), storage, nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewOutboundJSONBody_GetBodyReplaysFullBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"model":"test-model","messages":[{"role":"user","content":"hello"}]}`)
|
||||
|
||||
body, closer, err := NewOutboundJSONBody(payload)
|
||||
require.NoError(t, err)
|
||||
defer closer.Close()
|
||||
|
||||
assert.EqualValues(t, len(payload), body.Size())
|
||||
|
||||
// Consume the primary body, as the HTTP transport does on the first attempt.
|
||||
first, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, first)
|
||||
|
||||
// GetBody must hand out the complete body again — and repeatedly, since the
|
||||
// transport may need more than one retry.
|
||||
for i := range 2 {
|
||||
rc, err := body.NewReader()
|
||||
require.NoError(t, err)
|
||||
replay, err := io.ReadAll(rc)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, rc.Close())
|
||||
assert.Equal(t, payload, replay, "replay %d must equal the original payload", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOutboundJSONBody_GetBodyAfterPartialRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"model":"test-model","input":"0123456789"}`)
|
||||
|
||||
body, closer, err := NewOutboundJSONBody(payload)
|
||||
require.NoError(t, err)
|
||||
defer closer.Close()
|
||||
|
||||
// Simulate an aborted first attempt that only wrote part of the body.
|
||||
partial := make([]byte, 10)
|
||||
_, err = io.ReadFull(body, partial)
|
||||
require.NoError(t, err)
|
||||
|
||||
rc, err := body.NewReader()
|
||||
require.NoError(t, err)
|
||||
replay, err := io.ReadAll(rc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload, replay)
|
||||
|
||||
// Closing the replayed body must not close the underlying storage: the
|
||||
// handler owns the storage lifetime via the returned closer.
|
||||
require.NoError(t, rc.Close())
|
||||
rc2, err := body.NewReader()
|
||||
require.NoError(t, err)
|
||||
replay2, err := io.ReadAll(rc2)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, rc2.Close())
|
||||
assert.Equal(t, payload, replay2)
|
||||
}
|
||||
|
||||
// assertIndependentReplayReaders proves that readers handed out by getBody own
|
||||
// independent cursors, per the http.Request.GetBody contract of returning a
|
||||
// new copy of the body: interleaved reads across two replay readers and the
|
||||
// primary body each observe exactly their own byte stream.
|
||||
func assertIndependentReplayReaders(t *testing.T, payload []byte, body common.ReplayableBody) {
|
||||
t.Helper()
|
||||
|
||||
half := len(payload) / 2
|
||||
|
||||
// Partially drain the primary body first, as if attempt N's body write
|
||||
// were still in flight when the transport builds attempt N+1 via GetBody.
|
||||
primaryHead := make([]byte, half)
|
||||
_, err := io.ReadFull(body, primaryHead)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload[:half], primaryHead)
|
||||
|
||||
// Interleave two replay readers: A reads half, B reads everything, then A
|
||||
// reads the rest.
|
||||
a, err := body.NewReader()
|
||||
require.NoError(t, err)
|
||||
b, err := body.NewReader()
|
||||
require.NoError(t, err)
|
||||
|
||||
aHead := make([]byte, half)
|
||||
_, err = io.ReadFull(a, aHead)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload[:half], aHead)
|
||||
|
||||
bAll, err := io.ReadAll(b)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, b.Close())
|
||||
assert.Equal(t, payload, bAll, "reader B must see the complete body even while A is mid-read")
|
||||
|
||||
aRest, err := io.ReadAll(a)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, a.Close())
|
||||
assert.Equal(t, payload[half:], aRest, "reader A must resume from its own cursor, unaffected by B")
|
||||
|
||||
// The replays must not have disturbed the primary body's cursor either.
|
||||
primaryRest, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, payload[half:], primaryRest, "the primary body must be unaffected by replay readers")
|
||||
}
|
||||
|
||||
func TestNewOutboundJSONBody_GetBodyReadersAreIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := []byte(`{"model":"test-model","input":"abcdefghijklmnopqrstuvwxyz"}`)
|
||||
|
||||
body, closer, err := NewOutboundJSONBody(payload)
|
||||
require.NoError(t, err)
|
||||
defer closer.Close()
|
||||
|
||||
assertIndependentReplayReaders(t, payload, body)
|
||||
|
||||
// Once the handler releases the storage, GetBody must fail loudly instead
|
||||
// of replaying stale data.
|
||||
require.NoError(t, closer.Close())
|
||||
_, err = body.NewReader()
|
||||
require.ErrorIs(t, err, common.ErrStorageClosed)
|
||||
}
|
||||
|
||||
// TestNewOutboundJSONBody_GetBodyReadersAreIndependent_DiskStorage runs the
|
||||
// same independence assertions against the disk-backed storage. Deliberately
|
||||
// not parallel: it temporarily lowers the global disk-cache threshold so the
|
||||
// payload takes the diskStorage path.
|
||||
func TestNewOutboundJSONBody_GetBodyReadersAreIndependent_DiskStorage(t *testing.T) {
|
||||
prev := common.GetDiskCacheConfig()
|
||||
common.SetDiskCacheConfig(common.DiskCacheConfig{
|
||||
Enabled: true,
|
||||
ThresholdMB: 0,
|
||||
MaxSizeMB: 64,
|
||||
Path: t.TempDir(),
|
||||
})
|
||||
defer common.SetDiskCacheConfig(prev)
|
||||
|
||||
payload := []byte(`{"model":"test-model","input":"abcdefghijklmnopqrstuvwxyz"}`)
|
||||
|
||||
body, closer, err := NewOutboundJSONBody(payload)
|
||||
require.NoError(t, err)
|
||||
defer closer.Close()
|
||||
|
||||
storage, ok := closer.(common.BodyStorage)
|
||||
require.True(t, ok)
|
||||
assert.True(t, storage.IsDisk(), "the payload must have taken the diskStorage path")
|
||||
|
||||
assertIndependentReplayReaders(t, payload, body)
|
||||
|
||||
require.NoError(t, closer.Close())
|
||||
_, err = body.NewReader()
|
||||
require.ErrorIs(t, err, common.ErrStorageClosed)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRelayInfoConvOptionsUsesNormalizedGeminiSafetySettings(t *testing.T) {
|
||||
settings := model_setting.GetGeminiSettings()
|
||||
original := settings.SafetySettings
|
||||
t.Cleanup(func() {
|
||||
settings.SafetySettings = original
|
||||
})
|
||||
settings.SafetySettings = map[string]string{
|
||||
"HARM_CATEGORY_HATE_SPEECH": "",
|
||||
"HARM_CATEGORY_DANGEROUS_CONTENT": "BLOCK_ONLY_HIGH",
|
||||
}
|
||||
|
||||
options := (&RelayInfo{}).ConvOptions()
|
||||
|
||||
assert.Equal(t, "OFF", options.Gemini.SafetySetting("HARM_CATEGORY_HATE_SPEECH"))
|
||||
assert.Equal(t, "BLOCK_ONLY_HIGH", options.Gemini.SafetySetting("HARM_CATEGORY_DANGEROUS_CONTENT"))
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatPrefersExplicitFinal(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude},
|
||||
FinalRequestRelayFormat: types.RelayFormatOpenAIResponses,
|
||||
}
|
||||
|
||||
require.Equal(t, types.RelayFormat(types.RelayFormatOpenAIResponses), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatFallsBackToConversionChain(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatOpenAI,
|
||||
RequestConversionChain: []types.RelayFormat{types.RelayFormatOpenAI, types.RelayFormatClaude},
|
||||
}
|
||||
|
||||
require.Equal(t, types.RelayFormat(types.RelayFormatClaude), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatFallsBackToRelayFormat(t *testing.T) {
|
||||
info := &RelayInfo{
|
||||
RelayFormat: types.RelayFormatGemini,
|
||||
}
|
||||
|
||||
require.Equal(t, types.RelayFormat(types.RelayFormatGemini), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoGetFinalRequestRelayFormatNilReceiver(t *testing.T) {
|
||||
var info *RelayInfo
|
||||
require.Equal(t, types.RelayFormat(""), info.GetFinalRequestRelayFormat())
|
||||
}
|
||||
|
||||
func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
|
||||
var info *RelayInfo
|
||||
var meta convmeta.Meta = info
|
||||
|
||||
assert.Empty(t, meta.GetOriginModelName())
|
||||
assert.Empty(t, meta.GetUpstreamModelName())
|
||||
assert.False(t, meta.HasChannelMeta())
|
||||
assert.Zero(t, meta.GetChannelID())
|
||||
assert.Zero(t, meta.GetChannelType())
|
||||
assert.False(t, meta.GetIsStream())
|
||||
assert.Empty(t, meta.GetReasoningEffort())
|
||||
assert.Nil(t, meta.ReasoningState())
|
||||
assert.Zero(t, meta.GetEstimatePromptTokens())
|
||||
assert.Zero(t, meta.GetSendResponseCount())
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
meta.SetReasoningEffort("high")
|
||||
meta.IncrSendResponseCount()
|
||||
meta.AppendRequestConversion(types.RelayFormatClaude)
|
||||
})
|
||||
|
||||
firstState := meta.EnsureClaudeConvertInfo()
|
||||
secondState := meta.EnsureClaudeConvertInfo()
|
||||
require.NotNil(t, firstState)
|
||||
require.NotNil(t, secondState)
|
||||
assert.Equal(t, convmeta.LastMessageTypeNone, firstState.LastMessagesType)
|
||||
assert.NotSame(t, firstState, secondState)
|
||||
|
||||
firstOptions := meta.ConvOptions()
|
||||
secondOptions := meta.ConvOptions()
|
||||
require.NotNil(t, firstOptions)
|
||||
require.NotNil(t, secondOptions)
|
||||
assert.NotSame(t, firstOptions, secondOptions)
|
||||
assert.NotNil(t, firstOptions.Claude.DefaultMaxTokens)
|
||||
assert.NotNil(t, firstOptions.Gemini.SupportsImagine)
|
||||
assert.NotNil(t, firstOptions.Gemini.SafetySetting)
|
||||
assert.NotNil(t, firstOptions.PreserveThinkingSuffix)
|
||||
assert.NotNil(t, firstOptions.PreserveEffortTail)
|
||||
}
|
||||
|
||||
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
relayFormat types.RelayFormat
|
||||
request dto.Request
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "OpenAI chat top-level effort",
|
||||
path: "/v1/chat/completions",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
request: &dto.GeneralOpenAIRequest{Model: "gpt-5.6-sol", ReasoningEffort: " high "},
|
||||
expected: "high",
|
||||
},
|
||||
{
|
||||
name: "OpenRouter nested chat effort",
|
||||
path: "/v1/chat/completions",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
request: &dto.GeneralOpenAIRequest{Model: "anthropic/claude", Reasoning: json.RawMessage(`{"effort":"xhigh"}`)},
|
||||
expected: "xhigh",
|
||||
},
|
||||
{
|
||||
name: "OpenAI Responses effort",
|
||||
path: "/v1/responses",
|
||||
relayFormat: types.RelayFormatOpenAIResponses,
|
||||
request: &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: &dto.Reasoning{Effort: "max"}},
|
||||
expected: "max",
|
||||
},
|
||||
{
|
||||
name: "explicit none is preserved",
|
||||
path: "/v1/responses",
|
||||
relayFormat: types.RelayFormatOpenAIResponses,
|
||||
request: &dto.OpenAIResponsesRequest{Model: "gpt-5.6-sol", Reasoning: &dto.Reasoning{Effort: "none"}},
|
||||
expected: "none",
|
||||
},
|
||||
{
|
||||
name: "non-string nested effort is ignored",
|
||||
path: "/v1/chat/completions",
|
||||
relayFormat: types.RelayFormatOpenAI,
|
||||
request: &dto.GeneralOpenAIRequest{Model: "anthropic/claude", Reasoning: json.RawMessage(`{"effort":42}`)},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Claude output config effort",
|
||||
path: "/v1/messages",
|
||||
relayFormat: types.RelayFormatClaude,
|
||||
request: &dto.ClaudeRequest{Model: "claude-opus-4-7", OutputConfig: json.RawMessage(`{"effort":"medium"}`)},
|
||||
expected: "medium",
|
||||
},
|
||||
{
|
||||
name: "Gemini thinking level",
|
||||
path: "/v1beta/models/gemini-3-pro:generateContent",
|
||||
relayFormat: types.RelayFormatGemini,
|
||||
request: &dto.GeminiChatRequest{GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingLevel: "low"},
|
||||
}},
|
||||
expected: "low",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", tt.path, nil)
|
||||
|
||||
info, err := GenRelayInfo(ctx, tt.relayFormat, tt.request, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, info.ReasoningEffort)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenRelayInfoKeepsOriginAndLeavesBillingUnset(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
const model = "qwen3.8-max@thinking:on@temperature:0.2"
|
||||
ctx.Set("original_model", model)
|
||||
|
||||
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: model}, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model, info.OriginModelName)
|
||||
assert.Empty(t, info.BillingModelName)
|
||||
assert.Equal(t, model, info.GetBillingModelName())
|
||||
}
|
||||
|
||||
func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
request := &dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-5.6-sol",
|
||||
Reasoning: &dto.Reasoning{Effort: "max"},
|
||||
}
|
||||
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAIResponses, request, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
info.SetReasoningEffort("high")
|
||||
info.InitChannelMeta(ctx)
|
||||
assert.Equal(t, "max", info.ReasoningEffort)
|
||||
|
||||
info.SetReasoningEffort("low")
|
||||
info.InitChannelMeta(ctx)
|
||||
assert.Equal(t, "max", info.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestInitChannelMetaResetsPerAttemptStreamStateAndPreservesRequestState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
|
||||
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: "gpt-test"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
claudeState := relayconvert.NewClaudeToChatStreamState()
|
||||
_, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
|
||||
Type: "content_block_start",
|
||||
Index: ptr(7),
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: "toolu_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
|
||||
Type: "content_block_delta",
|
||||
Index: ptr(7),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: ptr(`{"q":"x"}`),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
geminiState, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
|
||||
ID: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
info.SendResponseCount = 3
|
||||
info.ClaudeToChatStreamState = claudeState
|
||||
info.ChatToGeminiStreamState = geminiState
|
||||
info.LastError = types.NewError(assert.AnError, types.ErrorCodeBadResponseBody)
|
||||
info.StreamStatus = NewStreamStatus()
|
||||
info.StreamStatus.RecordError("attempt 1 soft error")
|
||||
info.RecordConversionDiagnostics(context.Background(), []types.ConversionDiagnostic{{
|
||||
Code: "test.loss",
|
||||
Message: "attempt 1 conversion loss",
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAI,
|
||||
}})
|
||||
|
||||
info.InitChannelMeta(ctx)
|
||||
|
||||
assert.Zero(t, info.SendResponseCount)
|
||||
assert.Nil(t, info.ClaudeToChatStreamState)
|
||||
assert.Nil(t, info.ChatToGeminiStreamState)
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.True(t, info.StreamStatus.HasErrors())
|
||||
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||
diagnostics := info.ConversionDiagnostics()
|
||||
require.Len(t, diagnostics, 1)
|
||||
assert.Equal(t, "test.loss", diagnostics[0].Code)
|
||||
require.NotNil(t, info.LastError)
|
||||
|
||||
freshClaude := relayconvert.NewClaudeToChatStreamState()
|
||||
_, err = freshClaude.ConvertChunk(&dto.ClaudeResponse{
|
||||
Type: "content_block_delta",
|
||||
Index: ptr(7),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: ptr(`{"q":"x"}`),
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown content block index")
|
||||
|
||||
info.IncrSendResponseCount()
|
||||
responses := relayconvert.StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_retry",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||
}},
|
||||
}, info)
|
||||
require.NotEmpty(t, responses)
|
||||
assert.Equal(t, "message_start", responses[0].Type)
|
||||
}
|
||||
|
||||
func ptr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type HasPrompt interface {
|
||||
GetPrompt() string
|
||||
}
|
||||
|
||||
type HasImage interface {
|
||||
HasImage() bool
|
||||
}
|
||||
|
||||
func GetFullRequestURL(baseURL string, requestURL string, channelType int) string {
|
||||
fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL)
|
||||
|
||||
if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") {
|
||||
switch channelType {
|
||||
case constant.ChannelTypeOpenAI:
|
||||
fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1"))
|
||||
case constant.ChannelTypeAzure:
|
||||
fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/openai/deployments"))
|
||||
}
|
||||
}
|
||||
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")
|
||||
if apiVersion == "" {
|
||||
apiVersion = c.GetString("api_version")
|
||||
}
|
||||
return apiVersion
|
||||
}
|
||||
|
||||
func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError {
|
||||
return &dto.TaskError{
|
||||
Code: code,
|
||||
Message: err.Error(),
|
||||
StatusCode: statusCode,
|
||||
LocalError: localError,
|
||||
Error: err,
|
||||
}
|
||||
}
|
||||
|
||||
func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj TaskSubmitReq) {
|
||||
info.Action = action
|
||||
c.Set("task_request", requestObj)
|
||||
}
|
||||
func GetTaskRequest(c *gin.Context) (TaskSubmitReq, error) {
|
||||
v, exists := c.Get("task_request")
|
||||
if !exists {
|
||||
return TaskSubmitReq{}, fmt.Errorf("request not found in context")
|
||||
}
|
||||
req, ok := v.(TaskSubmitReq)
|
||||
if !ok {
|
||||
return TaskSubmitReq{}, fmt.Errorf("invalid task request type")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func validatePrompt(prompt string) *dto.TaskError {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaxTaskDurationSeconds caps user-supplied video duration. Duration is used
|
||||
// as a billing multiplier (OtherRatio "seconds"); an unbounded value could
|
||||
// overflow quota calculation into a negative charge.
|
||||
const MaxTaskDurationSeconds = 3600
|
||||
|
||||
func validateTaskDurationBounds(req TaskSubmitReq) *dto.TaskError {
|
||||
seconds := req.Duration
|
||||
if seconds == 0 && req.Seconds != "" {
|
||||
seconds, _ = strconv.Atoi(req.Seconds)
|
||||
}
|
||||
if seconds < 0 || seconds > MaxTaskDurationSeconds {
|
||||
return createTaskError(fmt.Errorf("seconds must be between 1 and %d", MaxTaskDurationSeconds), "invalid_seconds", http.StatusBadRequest, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMultipartTaskRequest(c *gin.Context, info *RelayInfo, action string) (TaskSubmitReq, error) {
|
||||
var req TaskSubmitReq
|
||||
if _, err := c.MultipartForm(); err != nil {
|
||||
return req, err
|
||||
}
|
||||
|
||||
formData := c.Request.PostForm
|
||||
req = TaskSubmitReq{
|
||||
Prompt: formData.Get("prompt"),
|
||||
Model: formData.Get("model"),
|
||||
Mode: formData.Get("mode"),
|
||||
Image: formData.Get("image"),
|
||||
Size: formData.Get("size"),
|
||||
Metadata: make(map[string]any),
|
||||
}
|
||||
|
||||
if durationStr := formData.Get("seconds"); durationStr != "" {
|
||||
if duration, err := strconv.Atoi(durationStr); err == nil {
|
||||
req.Duration = duration
|
||||
}
|
||||
}
|
||||
|
||||
if images := formData["images"]; len(images) > 0 {
|
||||
req.Images = images
|
||||
}
|
||||
|
||||
for key, values := range formData {
|
||||
if len(values) > 0 && !isKnownTaskField(key) {
|
||||
if intVal, err := strconv.Atoi(values[0]); err == nil {
|
||||
req.Metadata[key] = intVal
|
||||
} else if floatVal, err := strconv.ParseFloat(values[0], 64); err == nil {
|
||||
req.Metadata[key] = floatVal
|
||||
} else {
|
||||
req.Metadata[key] = values[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError {
|
||||
var prompt string
|
||||
var model string
|
||||
var seconds int
|
||||
var size string
|
||||
var hasInputReference bool
|
||||
|
||||
var req TaskSubmitReq
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_json", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
prompt = req.Prompt
|
||||
model = req.Model
|
||||
size = req.Size
|
||||
seconds, _ = strconv.Atoi(req.Seconds)
|
||||
if seconds == 0 {
|
||||
seconds = req.Duration
|
||||
}
|
||||
if req.InputReference != "" {
|
||||
req.Images = []string{req.InputReference}
|
||||
} else if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
|
||||
// 兼容单图上传
|
||||
req.Images = []string{strings.TrimSpace(req.Image)}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Model) == "" {
|
||||
return createTaskError(fmt.Errorf("model field is required"), "missing_model", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
if req.HasImage() {
|
||||
hasInputReference = true
|
||||
}
|
||||
|
||||
if taskErr := validatePrompt(prompt); taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
|
||||
if taskErr := validateTaskDurationBounds(req); taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
|
||||
action := constant.TaskActionTextToVideo
|
||||
if hasInputReference {
|
||||
action = constant.TaskActionImageToVideo
|
||||
}
|
||||
if strings.HasPrefix(model, "sora-2") {
|
||||
|
||||
if size == "" {
|
||||
size = "720x1280"
|
||||
}
|
||||
|
||||
if seconds <= 0 {
|
||||
seconds = 4
|
||||
}
|
||||
|
||||
if model == "sora-2" && !lo.Contains([]string{"720x1280", "1280x720"}, size) {
|
||||
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
|
||||
}
|
||||
if model == "sora-2-pro" && !lo.Contains([]string{"720x1280", "1280x720", "1792x1024", "1024x1792"}, size) {
|
||||
return createTaskError(fmt.Errorf("sora-2 size is invalid"), "invalid_size", http.StatusBadRequest, true)
|
||||
}
|
||||
// OtherRatios 已移到 Sora adaptor 的 EstimateBilling 中设置
|
||||
}
|
||||
|
||||
storeTaskRequest(c, info, action, req)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isKnownTaskField(field string) bool {
|
||||
knownFields := map[string]bool{
|
||||
"prompt": true,
|
||||
"model": true,
|
||||
"mode": true,
|
||||
"image": true,
|
||||
"images": true,
|
||||
"size": true,
|
||||
"duration": true,
|
||||
"input_reference": true, // Sora 特有字段
|
||||
}
|
||||
return knownFields[field]
|
||||
}
|
||||
|
||||
func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError {
|
||||
var err error
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
var req TaskSubmitReq
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
req, err = validateMultipartTaskRequest(c, info, action)
|
||||
if err != nil {
|
||||
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
|
||||
}
|
||||
}
|
||||
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
if taskErr := validatePrompt(req.Prompt); taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
|
||||
if taskErr := validateTaskDurationBounds(req); taskErr != nil {
|
||||
return taskErr
|
||||
}
|
||||
|
||||
if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" {
|
||||
// 兼容单图上传
|
||||
req.Images = []string{req.Image}
|
||||
}
|
||||
|
||||
storeTaskRequest(c, info, action, req)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 "}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/video/generations", body)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = request
|
||||
info := &RelayInfo{
|
||||
TaskRelayInfo: &TaskRelayInfo{},
|
||||
}
|
||||
|
||||
taskErr := ValidateMultipartDirect(context, info)
|
||||
|
||||
require.Nil(t, taskErr)
|
||||
storedReq, err := GetTaskRequest(context)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"https://example.com/first.png"}, storedReq.Images)
|
||||
require.Equal(t, constant.TaskActionImageToVideo, info.Action)
|
||||
}
|
||||
|
||||
// TestTaskDurationBounds guards the billing invariant that user-supplied
|
||||
// video duration (a quota multiplier via OtherRatio "seconds") is bounded, so
|
||||
// it can never overflow quota calculation into a negative charge.
|
||||
func TestTaskDurationBounds(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
newContext := func(t *testing.T, body string) (*gin.Context, *RelayInfo) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
return context, &RelayInfo{TaskRelayInfo: &TaskRelayInfo{}}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "huge duration is rejected",
|
||||
body: `{"model":"sora-2","prompt":"a cat","duration":9999999999}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "huge seconds string is rejected",
|
||||
body: `{"model":"sora-2","prompt":"a cat","seconds":"9999999999"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative duration is rejected",
|
||||
body: `{"model":"sora-2","prompt":"a cat","duration":-8}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "normal duration is accepted",
|
||||
body: `{"model":"sora-2","prompt":"a cat","seconds":"8"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+" (multipart direct)", func(t *testing.T) {
|
||||
context, info := newContext(t, tt.body)
|
||||
taskErr := ValidateMultipartDirect(context, info)
|
||||
if tt.wantErr {
|
||||
require.NotNil(t, taskErr)
|
||||
require.Equal(t, "invalid_seconds", taskErr.Code)
|
||||
} else {
|
||||
require.Nil(t, taskErr)
|
||||
}
|
||||
})
|
||||
t.Run(tt.name+" (basic task request)", func(t *testing.T) {
|
||||
context, info := newContext(t, tt.body)
|
||||
taskErr := ValidateBasicTaskRequest(context, info, constant.TaskActionImageToVideo)
|
||||
if tt.wantErr {
|
||||
require.NotNil(t, taskErr)
|
||||
require.Equal(t, "invalid_seconds", taskErr.Code)
|
||||
} else {
|
||||
require.Nil(t, taskErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
// GuessRelayFormatFromRequest moved to convmeta with the converters; the
|
||||
// delegation keeps host callers unchanged.
|
||||
func GuessRelayFormatFromRequest(req any) (types.RelayFormat, bool) {
|
||||
return convmeta.GuessRelayFormatFromRequest(req)
|
||||
}
|
||||
|
||||
func AppendRequestConversionFromRequest(info *RelayInfo, req any) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
format, ok := GuessRelayFormatFromRequest(req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info.AppendRequestConversion(format)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StreamEndReason string
|
||||
|
||||
const (
|
||||
StreamEndReasonNone StreamEndReason = ""
|
||||
StreamEndReasonDone StreamEndReason = "done"
|
||||
StreamEndReasonTimeout StreamEndReason = "timeout"
|
||||
StreamEndReasonClientGone StreamEndReason = "client_gone"
|
||||
StreamEndReasonScannerErr StreamEndReason = "scanner_error"
|
||||
StreamEndReasonHandlerStop StreamEndReason = "handler_stop"
|
||||
StreamEndReasonEOF StreamEndReason = "eof"
|
||||
StreamEndReasonPanic StreamEndReason = "panic"
|
||||
StreamEndReasonPingFail StreamEndReason = "ping_fail"
|
||||
)
|
||||
|
||||
const maxStreamErrorEntries = 20
|
||||
|
||||
type StreamErrorEntry struct {
|
||||
Message string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type StreamStatus struct {
|
||||
EndReason StreamEndReason
|
||||
EndError error
|
||||
endOnce sync.Once
|
||||
|
||||
mu sync.Mutex
|
||||
Errors []StreamErrorEntry
|
||||
ErrorCount int
|
||||
}
|
||||
|
||||
func NewStreamStatus() *StreamStatus {
|
||||
return &StreamStatus{}
|
||||
}
|
||||
|
||||
func (s *StreamStatus) SetEndReason(reason StreamEndReason, err error) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.endOnce.Do(func() {
|
||||
s.EndReason = reason
|
||||
s.EndError = err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StreamStatus) RecordError(msg string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ErrorCount++
|
||||
if len(s.Errors) < maxStreamErrorEntries {
|
||||
s.Errors = append(s.Errors, StreamErrorEntry{
|
||||
Message: msg,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StreamStatus) HasErrors() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ErrorCount > 0
|
||||
}
|
||||
|
||||
func (s *StreamStatus) TotalErrorCount() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.ErrorCount
|
||||
}
|
||||
|
||||
func (s *StreamStatus) IsNormalEnd() bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
return s.EndReason == StreamEndReasonDone ||
|
||||
s.EndReason == StreamEndReasonEOF ||
|
||||
s.EndReason == StreamEndReasonHandlerStop
|
||||
}
|
||||
|
||||
func (s *StreamStatus) Summary() string {
|
||||
if s == nil {
|
||||
return "StreamStatus<nil>"
|
||||
}
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "reason=%s", s.EndReason)
|
||||
if s.EndError != nil {
|
||||
fmt.Fprintf(b, " end_error=%q", s.EndError.Error())
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.ErrorCount > 0 {
|
||||
fmt.Fprintf(b, " soft_errors=%d", s.ErrorCount)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStreamStatus_SetEndReason_FirstWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
|
||||
s.SetEndReason(StreamEndReasonDone, nil)
|
||||
s.SetEndReason(StreamEndReasonTimeout, nil)
|
||||
s.SetEndReason(StreamEndReasonClientGone, fmt.Errorf("context canceled"))
|
||||
|
||||
assert.Equal(t, StreamEndReasonDone, s.EndReason)
|
||||
assert.Nil(t, s.EndError)
|
||||
}
|
||||
|
||||
func TestStreamStatus_SetEndReason_WithError(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
|
||||
expectedErr := fmt.Errorf("read: connection reset")
|
||||
s.SetEndReason(StreamEndReasonScannerErr, expectedErr)
|
||||
|
||||
assert.Equal(t, StreamEndReasonScannerErr, s.EndReason)
|
||||
assert.Equal(t, expectedErr, s.EndError)
|
||||
}
|
||||
|
||||
func TestStreamStatus_SetEndReason_NilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *StreamStatus
|
||||
s.SetEndReason(StreamEndReasonDone, nil)
|
||||
}
|
||||
|
||||
func TestStreamStatus_SetEndReason_Concurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
|
||||
reasons := []StreamEndReason{
|
||||
StreamEndReasonDone,
|
||||
StreamEndReasonTimeout,
|
||||
StreamEndReasonClientGone,
|
||||
StreamEndReasonScannerErr,
|
||||
StreamEndReasonHandlerStop,
|
||||
StreamEndReasonEOF,
|
||||
StreamEndReasonPanic,
|
||||
StreamEndReasonPingFail,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, r := range reasons {
|
||||
wg.Add(1)
|
||||
go func(reason StreamEndReason) {
|
||||
defer wg.Done()
|
||||
s.SetEndReason(reason, nil)
|
||||
}(r)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.NotEqual(t, StreamEndReasonNone, s.EndReason)
|
||||
}
|
||||
|
||||
func TestStreamStatus_RecordError_Basic(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
|
||||
s.RecordError("bad json")
|
||||
s.RecordError("another bad json")
|
||||
s.RecordError("client gone")
|
||||
|
||||
assert.True(t, s.HasErrors())
|
||||
assert.Equal(t, 3, s.TotalErrorCount())
|
||||
assert.Len(t, s.Errors, 3)
|
||||
}
|
||||
|
||||
func TestStreamStatus_RecordError_CapAtMax(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
|
||||
for i := range 30 {
|
||||
s.RecordError(fmt.Sprintf("error_%d", i))
|
||||
}
|
||||
|
||||
assert.Equal(t, maxStreamErrorEntries, len(s.Errors))
|
||||
assert.Equal(t, 30, s.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamStatus_RecordError_NilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *StreamStatus
|
||||
s.RecordError("should not panic")
|
||||
}
|
||||
|
||||
func TestStreamStatus_RecordError_Concurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := range 100 {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
s.RecordError(fmt.Sprintf("error_%d", idx))
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, 100, s.TotalErrorCount())
|
||||
assert.LessOrEqual(t, len(s.Errors), maxStreamErrorEntries)
|
||||
}
|
||||
|
||||
func TestStreamStatus_HasErrors_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := NewStreamStatus()
|
||||
assert.False(t, s.HasErrors())
|
||||
assert.Equal(t, 0, s.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamStatus_HasErrors_NilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *StreamStatus
|
||||
assert.False(t, s.HasErrors())
|
||||
assert.Equal(t, 0, s.TotalErrorCount())
|
||||
}
|
||||
|
||||
func TestStreamStatus_IsNormalEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
reason StreamEndReason
|
||||
normal bool
|
||||
}{
|
||||
{StreamEndReasonDone, true},
|
||||
{StreamEndReasonEOF, true},
|
||||
{StreamEndReasonHandlerStop, true},
|
||||
{StreamEndReasonTimeout, false},
|
||||
{StreamEndReasonClientGone, false},
|
||||
{StreamEndReasonScannerErr, false},
|
||||
{StreamEndReasonPanic, false},
|
||||
{StreamEndReasonPingFail, false},
|
||||
{StreamEndReasonNone, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
s := NewStreamStatus()
|
||||
s.SetEndReason(tt.reason, nil)
|
||||
assert.Equal(t, tt.normal, s.IsNormalEnd(), "reason=%s", tt.reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamStatus_IsNormalEnd_NilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *StreamStatus
|
||||
assert.True(t, s.IsNormalEnd())
|
||||
}
|
||||
|
||||
func TestStreamStatus_Summary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := NewStreamStatus()
|
||||
s.SetEndReason(StreamEndReasonDone, nil)
|
||||
summary := s.Summary()
|
||||
assert.Contains(t, summary, "reason=done")
|
||||
assert.NotContains(t, summary, "soft_errors")
|
||||
|
||||
s2 := NewStreamStatus()
|
||||
s2.SetEndReason(StreamEndReasonTimeout, nil)
|
||||
s2.RecordError("bad json")
|
||||
s2.RecordError("write failed")
|
||||
summary2 := s2.Summary()
|
||||
assert.Contains(t, summary2, "reason=timeout")
|
||||
assert.Contains(t, summary2, "soft_errors=2")
|
||||
}
|
||||
|
||||
func TestStreamStatus_Summary_NilSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s *StreamStatus
|
||||
assert.Equal(t, "StreamStatus<nil>", s.Summary())
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/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.GetBillingModelName()) <= 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/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 := range dto.MaxImageN + 3 {
|
||||
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))
|
||||
}
|
||||
Reference in new issue
Block a user