feat: sync deployed v1.0.0-rc.38-custom.42

Sync production source including routing, billing, Astra detection and public uptime. Update custom build instructions and license packaging.
This commit is contained in:
cneicy committed 2026-09-26 04:49:11 +08:00
1 parent aa6c3f50f4
commit ceddfd3d6b
828 files changed
+122253 -13702

No files matched your search

+6 -3
View File
@@ -88,7 +88,8 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
usage.ClaudeCacheCreation1hTokens != 0 {
return true
}
if usage.PromptTokensDetails.CachedTokens != 0 ||
if usage.PromptTokensDetails.CachedTokensDetails.HasTokens() ||
usage.PromptTokensDetails.CachedTokens != 0 ||
usage.PromptTokensDetails.CachedCreationTokens != 0 ||
usage.PromptTokensDetails.CacheWriteTokens != 0 ||
usage.PromptTokensDetails.TextTokens != 0 ||
@@ -105,7 +106,8 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
if usage.InputTokensDetails == nil {
return false
}
return usage.InputTokensDetails.CachedTokens != 0 ||
return usage.InputTokensDetails.CachedTokensDetails.HasTokens() ||
usage.InputTokensDetails.CachedTokens != 0 ||
usage.InputTokensDetails.CachedCreationTokens != 0 ||
usage.InputTokensDetails.CacheWriteTokens != 0 ||
usage.InputTokensDetails.TextTokens != 0 ||
@@ -385,8 +387,9 @@ func cloneOpenAIUsage(usage *Usage) *Usage {
}
clone := *usage
clone.BillingUsage = nil
clone.PromptTokensDetails = usage.PromptTokensDetails.Clone()
if usage.InputTokensDetails != nil {
inputTokensDetails := *usage.InputTokensDetails
inputTokensDetails := usage.InputTokensDetails.Clone()
clone.InputTokensDetails = &inputTokensDetails
}
return &clone
+27
View File
@@ -49,6 +49,33 @@ func TestNewOpenAIChatBillingUsageRequiresTokenContent(t *testing.T) {
assert.Equal(t, 1, billingUsage.OpenAIUsage.PromptTokens)
}
func TestImageCacheDetailsSurviveUsageSnapshots(t *testing.T) {
var original Usage
require.NoError(t, kitutil.Unmarshal([]byte(`{"input_tokens":1000,"input_tokens_details":{"cached_tokens":300,"image_tokens":600,"cached_tokens_details":{"image_tokens":200,"text_tokens":100}}}`), &original))
billing := NewOpenAIResponsesBillingUsage(&original)
require.NotNil(t, billing)
*original.InputTokensDetails.CachedTokensDetails.ImageTokens = 99
canonical, ok := billing.CanonicalUsage()
require.True(t, ok)
require.NotNil(t, canonical.PromptTokensDetails.CachedTokensDetails)
assert.Equal(t, 200, *canonical.PromptTokensDetails.CachedTokensDetails.ImageTokens)
*canonical.PromptTokensDetails.CachedTokensDetails.ImageTokens = 42
assert.Equal(t, 200, *billing.OpenAIUsage.InputTokensDetails.CachedTokensDetails.ImageTokens)
var incoming Usage
require.NoError(t, kitutil.Unmarshal([]byte(`{"input_tokens_details":{"cached_tokens_details":{"image_tokens":0}}}`), &incoming))
merged := MergeUsageNonZero(canonical, &incoming)
require.NotNil(t, merged.InputTokensDetails.CachedTokensDetails.ImageTokens)
assert.Zero(t, *merged.InputTokensDetails.CachedTokensDetails.ImageTokens)
assert.Equal(t, 100, *merged.InputTokensDetails.CachedTokensDetails.TextTokens)
*incoming.InputTokensDetails.CachedTokensDetails.ImageTokens = 9
assert.Zero(t, *merged.InputTokensDetails.CachedTokensDetails.ImageTokens)
encoded, err := kitutil.Marshal(merged)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"cached_tokens_details":{"text_tokens":100,"image_tokens":0}`)
assert.NotContains(t, string(encoded), `"audio_tokens":null`)
}
func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
billingUsage := NewEstimatedGeminiChatBillingUsage(&Usage{
PromptTokens: 11,
+53 -12
View File
@@ -11,13 +11,16 @@ import (
)
type ChannelSettings struct {
TaskPluginKey string `json:"task_plugin_key,omitempty"`
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
TaskPluginKey string `json:"task_plugin_key,omitempty"`
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
Proxy string `json:"proxy"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
ResponsesWebSocketEnabled bool `json:"responses_websocket_enabled,omitempty"`
// Deprecated: retained for stored settings compatibility; tokens control the guard.
ResponsesAstraModelGuard *bool `json:"responses_astra_model_guard,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
// HTTPProtocol controls outbound HTTP version negotiation for this channel.
// Accepted values: "", "auto" (default), "http1".
HTTPProtocol string `json:"http_protocol,omitempty"`
@@ -26,6 +29,10 @@ type ChannelSettings struct {
HTTP2ConnectionShards int `json:"http2_connection_shards,omitempty"`
}
func (s ChannelSettings) AstraModelGuardEnabled() bool {
return s.ResponsesAstraModelGuard == nil || *s.ResponsesAstraModelGuard
}
const (
HTTPProtocolAuto = "auto"
HTTPProtocolHTTP1 = "http1"
@@ -86,6 +93,10 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
// OllamaOpenAIChat routes Ollama chat completions to the OpenAI-compatible
// /v1/chat/completions endpoint. When unset, chat completions keep using
// the native /api/chat protocol.
OllamaOpenAIChat bool `json:"ollama_openai_chat,omitempty"`
// ToolLossPolicy is a channel-level opt-in for request-phase conversion
// rejection. Empty follows the default allow policy. Accepted values:
// "", "allow", "safe", "strict".
@@ -114,6 +125,7 @@ func (s *ChannelOtherSettings) ValidateToolLossPolicy() error {
}
const (
AdvancedCustomConverterSGLangRerank = "jina_rerank_to_sglang"
advancedCustomConverterNone = "none"
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
advancedCustomConverterOpenAIChatToClaudeMessages = "openai_chat_completions_to_anthropic_messages"
@@ -135,11 +147,22 @@ type AdvancedCustomConfig struct {
}
type AdvancedCustomRoute struct {
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
Models []string `json:"models,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
Models []string `json:"models,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
}
// SupportsPassThroughBody reports whether the route converter leaves the request body untouched.
func (r AdvancedCustomRoute) SupportsPassThroughBody() bool {
switch strings.TrimSpace(r.Converter) {
case "", advancedCustomConverterNone, AdvancedCustomConverterSGLangRerank:
return true
default:
return false
}
}
type AdvancedCustomRouteAuth struct {
@@ -171,6 +194,14 @@ const (
AdvancedCustomBalancePath = "/v1/dashboard/billing/credit_grants"
)
// IsNative reports whether the route forwards requests without protocol
// conversion. Validate normalizes an empty converter to none, but callers may
// see configurations that were never saved.
func (r AdvancedCustomRoute) IsNative() bool {
converter := strings.TrimSpace(r.Converter)
return converter == "" || converter == advancedCustomConverterNone
}
// MatchPath returns the first route whose IncomingPath matches requestPath.
// Matching mirrors the relay adaptor: exact match, {model} placeholder, and
// :generateContent <-> :streamGenerateContent equivalence.
@@ -373,6 +404,7 @@ func matchAdvancedCustomIncomingPathTemplate(configuredPath string, requestPath
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
case advancedCustomConverterNone,
AdvancedCustomConverterSGLangRerank,
advancedCustomConverterClaudeMessagesToOpenAIChat,
advancedCustomConverterOpenAIChatToClaudeMessages,
advancedCustomConverterOpenAIChatToOpenAIResponses,
@@ -438,6 +470,9 @@ func (c *AdvancedCustomConfig) Validate() error {
if strings.Contains(upstreamPath, advancedCustomModelPlaceholder) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must not contain %s for %s", i, advancedCustomModelPlaceholder, managementRouteName)
}
if route.PassThroughBodyEnabled {
return fmt.Errorf("advanced_custom.advanced_routes[%d].pass_through_body_enabled must be false for %s", i, managementRouteName)
}
}
if err := validateAdvancedCustomRouteModels(i, route.IncomingPath, route.Models, paths); err != nil {
return err
@@ -456,6 +491,9 @@ func (c *AdvancedCustomConfig) Validate() error {
if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil {
return err
}
if route.PassThroughBodyEnabled && !route.SupportsPassThroughBody() {
return fmt.Errorf("advanced_custom.advanced_routes[%d].pass_through_body_enabled requires converter none: %s", i, route.Converter)
}
if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil {
return err
}
@@ -556,6 +594,9 @@ func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error
}
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
if converter == AdvancedCustomConverterSGLangRerank && (incomingPath == "/v1/rerank" || incomingPath == "/rerank") {
return nil
}
if incomingPath == advancedCustomEndpointPathOpenAIAlphaSearch {
if converter == advancedCustomConverterNone {
return nil
+78
View File
@@ -585,6 +585,84 @@ func TestAdvancedCustomValidateAlphaSearchConverterPath(t *testing.T) {
}
}
func TestAdvancedCustomValidateRoutePassThroughBody(t *testing.T) {
valid := &AdvancedCustomConfig{
Routes: []AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
PassThroughBodyEnabled: true,
},
{
IncomingPath: "/v1/rerank",
UpstreamPath: "/v1/rerank",
Converter: AdvancedCustomConverterSGLangRerank,
PassThroughBodyEnabled: true,
},
{
IncomingPath: "/v1/messages",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterClaudeMessagesToOpenAIChat,
},
},
}
require.NoError(t, valid.Validate())
route, ok := valid.MatchPathForModel("/v1/chat/completions", "gpt-4o")
require.True(t, ok)
assert.True(t, route.PassThroughBodyEnabled)
route, ok = valid.MatchPathForModel("/v1/messages", "gpt-4o")
require.True(t, ok)
assert.False(t, route.PassThroughBodyEnabled)
encoded, err := json.Marshal(valid.Routes[2])
require.NoError(t, err)
assert.NotContains(t, string(encoded), "pass_through_body_enabled")
tests := []struct {
name string
route AdvancedCustomRoute
want string
}{
{
name: "converter route",
route: AdvancedCustomRoute{
IncomingPath: "/v1/messages",
UpstreamPath: "/v1/chat/completions",
Converter: advancedCustomConverterClaudeMessagesToOpenAIChat,
PassThroughBodyEnabled: true,
},
want: "pass_through_body_enabled requires converter none",
},
{
name: "model list route",
route: AdvancedCustomRoute{
IncomingPath: AdvancedCustomModelListPath,
UpstreamPath: "/v1/models",
PassThroughBodyEnabled: true,
},
want: "pass_through_body_enabled must be false for /v1/models",
},
{
name: "balance route",
route: AdvancedCustomRoute{
IncomingPath: AdvancedCustomBalancePath,
UpstreamPath: "/provider/balance",
PassThroughBodyEnabled: true,
},
want: "pass_through_body_enabled must be false for /v1/dashboard/billing/credit_grants",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := (&AdvancedCustomConfig{Routes: []AdvancedCustomRoute{tt.route}}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), tt.want)
})
}
}
func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) {
legacy := `{"proxy":"http://127.0.0.1:8080","force_format":true}`
var settings ChannelSettings
+2
View File
@@ -246,6 +246,8 @@ type ClaudeRequest struct {
Thinking *Thinking `json:"thinking,omitempty"`
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// vLLM Messages extension; forwarded verbatim when present.
ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"`
// Speed specifies the Claude inference speed mode.
// This field is filtered by default and can be enabled via channel setting allow_speed.
Speed json.RawMessage `json:"speed,omitempty"`
+58
View File
@@ -0,0 +1,58 @@
package dto
// Deprecated model compatibility: OpenAI removed DALL·E 2 and DALL·E 3 from
// its API on 2026-05-12. Retain these rules for existing configurations and
// compatible upstreams; do not extend them for current image models.
// https://developers.openai.com/api/docs/deprecations#2025-11-14-dalle-model-snapshots
import (
"errors"
"strings"
)
// NormalizeLegacyDalleImageRequest preserves the retired models' request
// validation and defaults. Other models, including aliases, are unchanged.
func (i *ImageRequest) NormalizeLegacyDalleImageRequest() error {
switch i.Model {
case "dall-e-2", "dall-e":
if i.Size != "" && i.Size != "256x256" && i.Size != "512x512" && i.Size != "1024x1024" {
return errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e")
}
if i.Size == "" {
i.Size = "1024x1024"
}
case "dall-e-3":
if i.Size != "" && i.Size != "1024x1024" && i.Size != "1024x1792" && i.Size != "1792x1024" {
return errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3")
}
if i.Quality == "" {
i.Quality = "standard"
}
if i.Size == "" {
i.Size = "1024x1024"
}
}
return nil
}
func (i *ImageRequest) legacyDallePriceRatio() float64 {
if !strings.HasPrefix(i.Model, "dall-e") {
return 1
}
sizeRatio, qualityRatio := 1.0, 1.0
switch i.Size {
case "256x256":
sizeRatio = 0.4
case "512x512":
sizeRatio = 0.45
case "1024x1792", "1792x1024":
sizeRatio = 2
}
if i.Model == "dall-e-3" && i.Quality == "hd" {
qualityRatio = 2
if i.Size == "1024x1792" || i.Size == "1792x1024" {
qualityRatio = 1.5
}
}
return sizeRatio * qualityRatio
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
type OpenAIResponsesCompactionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
CreatedAt IntValue `json:"created_at"`
Output json.RawMessage `json:"output"`
Usage *Usage `json:"usage"`
Error any `json:"error,omitempty"`
+32 -26
View File
@@ -2,9 +2,9 @@ package dto
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -14,6 +14,13 @@ import (
// wrapped-negative n overflows quota calculation into a negative charge.
const MaxImageN = 128
// ImageBillingParameters contains only the provider scalars parsed by request
// validation. Keep this separate from the complete provider request payload.
type ImageBillingParameters struct {
N *uint `json:"n,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
}
type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt" binding:"required"`
@@ -39,7 +46,29 @@ type ImageRequest struct {
UserId json.RawMessage `json:"user_id,omitempty"`
Image json.RawMessage `json:"image,omitempty"`
// 用匿名参数接收额外参数
Extra map[string]json.RawMessage `json:"-"`
Extra map[string]json.RawMessage `json:"-"`
BillingParameters *ImageBillingParameters `json:"-"`
}
// ImageCount resolves the validated request quantity. Top-level zero retains
// its legacy default of one; an explicit provider count must be positive.
func (i *ImageRequest) ImageCount(useProviderParameters bool) (int, error) {
n := uint(1)
if i.N != nil && *i.N != 0 {
n = *i.N
}
if n > MaxImageN {
return 0, fmt.Errorf("n must be an integer between 1 and %d", MaxImageN)
}
if parameters := i.BillingParameters; parameters != nil && parameters.N != nil {
if *parameters.N > MaxImageN || useProviderParameters && *parameters.N == 0 {
return 0, fmt.Errorf("parameters.n must be an integer between 1 and %d", MaxImageN)
}
if useProviderParameters {
n = *parameters.N
}
}
return int(n), nil
}
func (i *ImageRequest) UnmarshalJSON(data []byte) error {
@@ -131,29 +160,6 @@ func indexComma(s string) int {
}
func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
var sizeRatio = 1.0
var qualityRatio = 1.0
if strings.HasPrefix(i.Model, "dall-e") {
// Size
if i.Size == "256x256" {
sizeRatio = 0.4
} else if i.Size == "512x512" {
sizeRatio = 0.45
} else if i.Size == "1024x1024" {
sizeRatio = 1
} else if i.Size == "1024x1792" || i.Size == "1792x1024" {
sizeRatio = 2
}
if i.Model == "dall-e-3" && i.Quality == "hd" {
qualityRatio = 2.0
if i.Size == "1024x1792" || i.Size == "1792x1024" {
qualityRatio = 1.5
}
}
}
imageN := uint(1)
if i.N != nil && *i.N > 0 {
imageN = *i.N
@@ -165,7 +171,7 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
return &types.TokenCountMeta{
CombineText: i.Prompt,
MaxTokens: 1584,
ImagePriceRatio: sizeRatio * qualityRatio,
ImagePriceRatio: i.legacyDallePriceRatio(),
BillingRatios: map[string]float64{"n": float64(imageN)},
}
}
+30
View File
@@ -109,6 +109,25 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
// vLLM
ThinkingTokenBudget json.RawMessage `json:"thinking_token_budget,omitempty"`
IncludeReasoning json.RawMessage `json:"include_reasoning,omitempty"`
MinP json.RawMessage `json:"min_p,omitempty"`
RepetitionPenalty json.RawMessage `json:"repetition_penalty,omitempty"`
StructuredOutputs json.RawMessage `json:"structured_outputs,omitempty"`
ReturnTokenIds json.RawMessage `json:"return_token_ids,omitempty"`
// SGLang OpenAI-compatible sampling and reasoning controls (v0.5.19).
// Native /generate sampling_params and server routing controls are not chat fields.
MinTokens *uint `json:"min_tokens,omitempty"`
SeparateReasoning json.RawMessage `json:"separate_reasoning,omitempty"`
StreamReasoning json.RawMessage `json:"stream_reasoning,omitempty"`
Regex json.RawMessage `json:"regex,omitempty"`
EBNF json.RawMessage `json:"ebnf,omitempty"`
StopTokenIDs json.RawMessage `json:"stop_token_ids,omitempty"`
StopRegex json.RawMessage `json:"stop_regex,omitempty"`
NoStopTrim json.RawMessage `json:"no_stop_trim,omitempty"`
IgnoreEOS json.RawMessage `json:"ignore_eos,omitempty"`
SkipSpecialTokens json.RawMessage `json:"skip_special_tokens,omitempty"`
ContinueFinalMessage json.RawMessage `json:"continue_final_message,omitempty"`
CacheSalt json.RawMessage `json:"cache_salt,omitempty"`
// Internal conversion state; never serialized to an upstream protocol.
ReasoningConversion *ReasoningConversionState `json:"-"`
@@ -386,6 +405,9 @@ type StreamOptions struct {
// IncludeObfuscation is only for /v1/responses stream payload.
// This field is filtered by default and can be enabled via channel setting allow_include_obfuscation.
IncludeObfuscation bool `json:"include_obfuscation,omitempty"`
// ContinuousUsageStats is a vLLM stream_options extension that emits
// usage on intermediate chunks. Optional so an explicit false is kept.
// ContinuousUsageStats *bool `json:"continuous_usage_stats,omitempty"`
}
func (r *GeneralOpenAIRequest) GetMaxTokens() uint {
@@ -1029,6 +1051,14 @@ type OpenAIResponsesRequest struct {
// qwen
EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
// vLLM
ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"`
// SGLang Responses sampling extensions.
TopK json.RawMessage `json:"top_k,omitempty"`
MinP json.RawMessage `json:"min_p,omitempty"`
RepetitionPenalty json.RawMessage `json:"repetition_penalty,omitempty"`
Stop json.RawMessage `json:"stop,omitempty"`
CacheSalt json.RawMessage `json:"cache_salt,omitempty"`
// perplexity
Preset json.RawMessage `json:"preset,omitempty"`
@@ -307,3 +307,23 @@ func TestGeneralOpenAIRequestPreserveMessageLevelTools(t *testing.T) {
assert.Contains(t, meta.CombineText, "Get the current time of a city")
assert.Contains(t, meta.CombineText, "lookup_order")
}
func TestSGLangRequestExtensionsRoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
raw string
request any
}{
{"chat", `{"model":"served-model","min_tokens":0,"top_k":-1,"min_p":0,"repetition_penalty":1,"separate_reasoning":false,"stream_reasoning":false,"regex":"[0-9]+","ebnf":"root ::= \"yes\"","stop_token_ids":[0],"stop_regex":["END$"],"no_stop_trim":false,"ignore_eos":false,"skip_special_tokens":false,"continue_final_message":false,"cache_salt":"tenant","chat_template_kwargs":{"enable_thinking":false}}`, &GeneralOpenAIRequest{}},
{"responses", `{"model":"served-model","input":"hi","top_k":-1,"min_p":0,"repetition_penalty":1,"frequency_penalty":0,"presence_penalty":0,"stop":["END"],"cache_salt":"tenant","chat_template_kwargs":{"enable_thinking":false}}`, &OpenAIResponsesRequest{}},
{"chat omitted", `{"model":"served-model"}`, &GeneralOpenAIRequest{}},
{"responses omitted", `{"model":"served-model"}`, &OpenAIResponsesRequest{}},
} {
t.Run(tc.name, func(t *testing.T) {
require.NoError(t, kitutil.Unmarshal([]byte(tc.raw), tc.request))
encoded, err := kitutil.Marshal(tc.request)
require.NoError(t, err)
assert.JSONEq(t, tc.raw, string(encoded))
})
}
}
+44 -8
View File
@@ -234,11 +234,12 @@ type Usage struct {
UsageSource string `json:"usage_source,omitempty"`
BillingUsage *BillingUsage `json:"billing_usage,omitempty"`
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"`
CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputTokensDetails *InputTokenDetails `json:"input_tokens_details"`
OutputTokensDetails *OutputTokenDetails `json:"output_tokens_details,omitempty"`
// claude cache 1h
ClaudeCacheCreation5mTokens int `json:"claude_cache_creation_5_m_tokens"`
@@ -259,8 +260,9 @@ type OpenAIVideoResponse struct {
}
type InputTokenDetails struct {
CachedTokens int `json:"cached_tokens"`
CachedCreationTokens int `json:"cached_creation_tokens,omitempty"`
CachedTokens int `json:"cached_tokens"`
CachedTokensDetails *CachedTokenDetails `json:"cached_tokens_details,omitempty"`
CachedCreationTokens int `json:"cached_creation_tokens,omitempty"`
// CacheWriteTokens is OpenAI's native cache-write count, reported as
// prompt_tokens_details.cache_write_tokens (Chat Completions) or
// input_tokens_details.cache_write_tokens (Responses). It is billed at the
@@ -271,6 +273,40 @@ type InputTokenDetails struct {
ImageTokens int `json:"image_tokens"`
}
// CachedTokenDetails describes subsets of cached_tokens. Pointers distinguish
// an unreported modality from an explicitly reported zero.
type CachedTokenDetails struct {
TextTokens *int `json:"text_tokens,omitempty"`
ImageTokens *int `json:"image_tokens,omitempty"`
AudioTokens *int `json:"audio_tokens,omitempty"`
}
func (d *CachedTokenDetails) HasTokens() bool {
return d != nil && (d.TextTokens != nil && *d.TextTokens != 0 ||
d.ImageTokens != nil && *d.ImageTokens != 0 || d.AudioTokens != nil && *d.AudioTokens != 0)
}
// Clone preserves presence information without sharing mutable usage fields.
func (d InputTokenDetails) Clone() InputTokenDetails {
if d.CachedTokensDetails != nil {
cached := *d.CachedTokensDetails
if cached.TextTokens != nil {
value := *cached.TextTokens
cached.TextTokens = &value
}
if cached.ImageTokens != nil {
value := *cached.ImageTokens
cached.ImageTokens = &value
}
if cached.AudioTokens != nil {
value := *cached.AudioTokens
cached.AudioTokens = &value
}
d.CachedTokensDetails = &cached
}
return d
}
// CacheCreationTokensTotal returns the cache-write token count regardless of
// which field the upstream reported it in: Claude-derived conversions populate
// CachedCreationTokens while OpenAI reports cache_write_tokens natively. Both
@@ -295,7 +331,7 @@ type OutputTokenDetails struct {
type OpenAIResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int `json:"created_at"`
CreatedAt IntValue `json:"created_at"`
Status json.RawMessage `json:"status"`
Error any `json:"error,omitempty"`
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
+20 -1
View File
@@ -50,7 +50,7 @@ func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage {
details.CacheWriteTokens > 0 ||
details.TextTokens > 0 ||
details.AudioTokens > 0 ||
details.ImageTokens > 0 {
details.ImageTokens > 0 || details.CachedTokensDetails != nil {
if current.InputTokensDetails == nil {
current.InputTokensDetails = &InputTokenDetails{}
}
@@ -286,6 +286,25 @@ func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []Gem
}
func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetails) {
if incoming.CachedTokensDetails != nil {
// Unlike legacy scalar counters, these optional fields explicitly report
// zero. Merge only present modalities and detach the resulting snapshot.
merged := current.Clone()
if merged.CachedTokensDetails == nil {
merged.CachedTokensDetails = &CachedTokenDetails{}
}
details := incoming.Clone().CachedTokensDetails
if details.TextTokens != nil {
merged.CachedTokensDetails.TextTokens = details.TextTokens
}
if details.ImageTokens != nil {
merged.CachedTokensDetails.ImageTokens = details.ImageTokens
}
if details.AudioTokens != nil {
merged.CachedTokensDetails.AudioTokens = details.AudioTokens
}
current.CachedTokensDetails = merged.CachedTokensDetails
}
if incoming.CachedTokens > 0 {
current.CachedTokens = incoming.CachedTokens
}
+1
View File
@@ -16,6 +16,7 @@ type UserSetting struct {
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en)
AstraCostNoticeAck bool `json:"astra_cost_notice_ack,omitempty"` // Astra 费用说明已确认
}
var (
+5
View File
@@ -37,6 +37,11 @@ func (i *IntValue) UnmarshalJSON(b []byte) error {
*i = IntValue(n)
return nil
}
var f float64
if err := kitutil.Unmarshal(b, &f); err == nil {
*i = IntValue(int(f))
return nil
}
var s string
if err := kitutil.Unmarshal(b, &s); err != nil {
return err
+47
View File
@@ -0,0 +1,47 @@
package dto
import (
"testing"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIntValueUnmarshalJSON(t *testing.T) {
testCases := []struct {
name string
input string
expected int
}{
{name: "integer", input: `1768488160`, expected: 1768488160},
{name: "float", input: `1786588600.0`, expected: 1786588600},
{name: "scientific", input: `1.76848816E9`, expected: 1768488160},
{name: "string integer", input: `"42"`, expected: 42},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var v IntValue
require.NoError(t, kitutil.UnmarshalJsonStr(tc.input, &v))
assert.Equal(t, tc.expected, int(v))
})
}
}
func TestResponsesStreamResponseCreatedAtFloat(t *testing.T) {
var resp ResponsesStreamResponse
require.NoError(t, kitutil.UnmarshalJsonStr(`{"response":{"id":"resp_1","created_at":1786588600.0}}`, &resp))
require.NotNil(t, resp.Response)
assert.Equal(t, 1786588600, int(resp.Response.CreatedAt))
encoded, err := kitutil.Marshal(resp.Response)
require.NoError(t, err)
assert.Contains(t, string(encoded), `"created_at":1786588600`)
assert.NotContains(t, string(encoded), `1786588600.0`)
}
func TestResponsesCompactionResponseCreatedAtFloat(t *testing.T) {
var resp OpenAIResponsesCompactionResponse
require.NoError(t, kitutil.UnmarshalJsonStr(`{"id":"resp_1","object":"response","created_at":1.76848816E9}`, &resp))
assert.Equal(t, 1768488160, int(resp.CreatedAt))
}