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:
1 parent
aa6c3f50f4
commit
ceddfd3d6b
828 files changed
+122253
-13702
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user