feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -21,12 +22,46 @@ type ChannelSettings struct {
|
||||
ResponsesAstraModelGuard *bool `json:"responses_astra_model_guard,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt,omitempty"`
|
||||
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
|
||||
// TaskExtendPluginKeys lists the task plugins a New API channel (type 60)
|
||||
// is extended with. The upstream gateway may host many plugins, so the
|
||||
// channel serves every listed plugin's models while the request still pins
|
||||
// the executing plugin. TaskPluginKey remains the single type-61 binding
|
||||
// and stays valid on a New API channel as well.
|
||||
TaskExtendPluginKeys []string `json:"task_extend_plugin_keys,omitempty"`
|
||||
// HTTPProtocol controls outbound HTTP version negotiation for this channel.
|
||||
// Accepted values: "", "auto" (default), "http1".
|
||||
HTTPProtocol string `json:"http_protocol,omitempty"`
|
||||
// HTTP2ConnectionShards spreads HTTP/2 traffic across N independent transports
|
||||
// (1-8). Zero/unset means 1. Ignored when HTTPProtocol is "http1".
|
||||
HTTP2ConnectionShards int `json:"http2_connection_shards,omitempty"`
|
||||
// TLSInsecureSkipVerify accepts upstream certificates that fail
|
||||
// verification, such as self-signed ones, for this channel only.
|
||||
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify,omitempty"`
|
||||
}
|
||||
|
||||
// BindsTaskPlugin reports whether the channel is bound to the task plugin,
|
||||
// either through the single binding or the New API extension list.
|
||||
func (s ChannelSettings) BindsTaskPlugin(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
return s.TaskPluginKey == key || slices.Contains(s.TaskExtendPluginKeys, key)
|
||||
}
|
||||
|
||||
// TaskPluginBindings returns the sorted, de-duplicated set of task plugins the
|
||||
// channel binds through either field, so callers can compare bindings as a set.
|
||||
func (s ChannelSettings) TaskPluginBindings() []string {
|
||||
bindings := make([]string, 0, len(s.TaskExtendPluginKeys)+1)
|
||||
if s.TaskPluginKey != "" {
|
||||
bindings = append(bindings, s.TaskPluginKey)
|
||||
}
|
||||
for _, key := range s.TaskExtendPluginKeys {
|
||||
if key != "" && !slices.Contains(bindings, key) {
|
||||
bindings = append(bindings, key)
|
||||
}
|
||||
}
|
||||
slices.Sort(bindings)
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (s ChannelSettings) AstraModelGuardEnabled() bool {
|
||||
@@ -73,9 +108,20 @@ const (
|
||||
AwsKeyTypeApiKey AwsKeyType = "api_key"
|
||||
)
|
||||
|
||||
type AwsApiType string
|
||||
|
||||
const (
|
||||
AwsApiTypeInvokeModel AwsApiType = "invoke_model"
|
||||
AwsApiTypeMantle AwsApiType = "mantle"
|
||||
)
|
||||
|
||||
type ChannelOtherSettings struct {
|
||||
// Exact upstream model names whose Messages input_tokens includes cache reads.
|
||||
// Empty preserves standard Anthropic accounting; cache writes remain separate.
|
||||
ClaudeInputIncludesCacheReadModels []string `json:"claude_input_includes_cache_read_models,omitempty"`
|
||||
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
|
||||
VertexProjectID string `json:"vertex_project_id,omitempty"` // 仅当 vertex_key_type=api_key 时使用;JSON 模式下从 service account 解析
|
||||
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
@@ -86,6 +132,7 @@ type ChannelOtherSettings struct {
|
||||
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
|
||||
DisableTaskPollingSleep bool `json:"disable_task_polling_sleep,omitempty"` // 是否跳过异步任务轮询间隔
|
||||
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
|
||||
AwsApiType AwsApiType `json:"aws_api_type,omitempty"`
|
||||
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
|
||||
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新
|
||||
UpstreamModelUpdateLastCheckTime int64 `json:"upstream_model_update_last_check_time,omitempty"` // 上次检测时间
|
||||
@@ -134,6 +181,10 @@ const (
|
||||
advancedCustomConverterOpenAIResponsesToGemini = "openai_responses_to_gemini_generate_content"
|
||||
advancedCustomConverterGeminiContentToOpenAIChat = "gemini_generate_content_to_openai_chat_completions"
|
||||
advancedCustomConverterOpenAIChatToGeminiContent = "openai_chat_completions_to_gemini_generate_content"
|
||||
// Same values as relayconvert.ConverterClaudeMessagesToOpenAIResponses and
|
||||
// relayconvert.ConverterGeminiContentToOpenAIResponses.
|
||||
advancedCustomConverterClaudeMessagesToOpenAIResponses = "claude_messages_to_openai_responses"
|
||||
advancedCustomConverterGeminiContentToOpenAIResponses = "gemini_generate_content_to_openai_responses"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -181,6 +232,8 @@ const (
|
||||
advancedCustomEndpointPathOpenAIResponses = "/v1/responses"
|
||||
advancedCustomEndpointPathOpenAIResponsesCompact = "/v1/responses/compact"
|
||||
advancedCustomEndpointPathOpenAIAlphaSearch = "/v1/alpha/search"
|
||||
advancedCustomEndpointPathDeepSeekToolsSearch = "/v1/tools/search"
|
||||
advancedCustomEndpointPathDeepSeekToolsFetch = "/v1/tools/fetch"
|
||||
advancedCustomEndpointPathClaudeMessages = "/v1/messages"
|
||||
advancedCustomEndpointPathJinaRerank = "/v1/rerank"
|
||||
advancedCustomEndpointPathImageGeneration = "/v1/images/generations"
|
||||
@@ -306,6 +359,10 @@ func advancedCustomEndpointTypeFromIncomingPath(incomingPath string) (types.Endp
|
||||
return types.EndpointTypeOpenAIResponseCompact, true
|
||||
case advancedCustomEndpointPathOpenAIAlphaSearch:
|
||||
return types.EndpointTypeOpenAIAlphaSearch, true
|
||||
case advancedCustomEndpointPathDeepSeekToolsSearch:
|
||||
return types.EndpointTypeDeepSeekToolsSearch, true
|
||||
case advancedCustomEndpointPathDeepSeekToolsFetch:
|
||||
return types.EndpointTypeDeepSeekToolsFetch, true
|
||||
case advancedCustomEndpointPathClaudeMessages:
|
||||
return types.EndpointTypeAnthropic, true
|
||||
case advancedCustomEndpointPathJinaRerank:
|
||||
@@ -411,7 +468,9 @@ func IsAdvancedCustomConverterAllowed(converter string) bool {
|
||||
advancedCustomConverterOpenAIResponsesToOpenAIChat,
|
||||
advancedCustomConverterOpenAIResponsesToGemini,
|
||||
advancedCustomConverterGeminiContentToOpenAIChat,
|
||||
advancedCustomConverterOpenAIChatToGeminiContent:
|
||||
advancedCustomConverterOpenAIChatToGeminiContent,
|
||||
advancedCustomConverterClaudeMessagesToOpenAIResponses,
|
||||
advancedCustomConverterGeminiContentToOpenAIResponses:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -606,7 +665,8 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert
|
||||
switch converter {
|
||||
case advancedCustomConverterNone:
|
||||
return nil
|
||||
case advancedCustomConverterClaudeMessagesToOpenAIChat:
|
||||
case advancedCustomConverterClaudeMessagesToOpenAIChat,
|
||||
advancedCustomConverterClaudeMessagesToOpenAIResponses:
|
||||
if incomingPath == "/v1/messages" {
|
||||
return nil
|
||||
}
|
||||
@@ -624,7 +684,8 @@ func validateAdvancedCustomConverterPath(index int, incomingPath string, convert
|
||||
if incomingPath == "/v1/responses" {
|
||||
return nil
|
||||
}
|
||||
case advancedCustomConverterGeminiContentToOpenAIChat:
|
||||
case advancedCustomConverterGeminiContentToOpenAIChat,
|
||||
advancedCustomConverterGeminiContentToOpenAIResponses:
|
||||
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -676,20 +676,24 @@ func TestChannelSettingsHTTPTransportJSONRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(encoded), "http_protocol")
|
||||
assert.NotContains(t, string(encoded), "http2_connection_shards")
|
||||
assert.NotContains(t, string(encoded), "tls_insecure_skip_verify")
|
||||
|
||||
explicit := ChannelSettings{
|
||||
Proxy: "socks5://127.0.0.1:1080",
|
||||
HTTPProtocol: HTTPProtocolHTTP1,
|
||||
HTTP2ConnectionShards: 1,
|
||||
TLSInsecureSkipVerify: true,
|
||||
}
|
||||
encoded, err = json.Marshal(explicit)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(encoded), `"http_protocol":"http1"`)
|
||||
assert.Contains(t, string(encoded), `"tls_insecure_skip_verify":true`)
|
||||
|
||||
var decoded ChannelSettings
|
||||
require.NoError(t, json.Unmarshal(encoded, &decoded))
|
||||
assert.Equal(t, explicit.HTTPProtocol, decoded.HTTPProtocol)
|
||||
assert.Equal(t, 1, decoded.HTTP2ConnectionShards)
|
||||
assert.True(t, decoded.TLSInsecureSkipVerify)
|
||||
|
||||
sharded := ChannelSettings{HTTP2ConnectionShards: 4}
|
||||
encoded, err = json.Marshal(sharded)
|
||||
|
||||
+80
-15
@@ -3,6 +3,7 @@ package dto
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -135,6 +136,9 @@ type ClaudeMessageSource struct {
|
||||
type ClaudeMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content"`
|
||||
// OutputConfig carries per-message effort (beta): an effort-only system
|
||||
// message has empty content and the new level in output_config.effort.
|
||||
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ClaudeMessage) IsStringContent() bool {
|
||||
@@ -238,6 +242,7 @@ type ClaudeRequest struct {
|
||||
TopK *int `json:"top_k,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Tools any `json:"tools,omitempty"`
|
||||
Safeguards json.RawMessage `json:"safeguards,omitempty"`
|
||||
ContextManagement json.RawMessage `json:"context_management,omitempty"`
|
||||
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||
OutputFormat json.RawMessage `json:"output_format,omitempty"`
|
||||
@@ -332,9 +337,25 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
texts = append(texts, string(b))
|
||||
}
|
||||
case "tool_result":
|
||||
if media.Content != nil {
|
||||
b, _ := kitutil.Marshal(media.Content)
|
||||
texts = append(texts, string(b))
|
||||
// Count tool output as the text the model reads; JSON-encoding it
|
||||
// would escape newlines and quotes and count image base64 as text.
|
||||
if media.IsStringContent() {
|
||||
texts = append(texts, media.GetStringContent())
|
||||
break
|
||||
}
|
||||
for _, part := range media.ParseMediaContent() {
|
||||
if source := part.ToFileSource(); source != nil {
|
||||
fileType := types.FileTypeFile
|
||||
if part.Type == "image" {
|
||||
fileType = types.FileTypeImage
|
||||
}
|
||||
fileMeta = append(fileMeta, &types.FileMeta{FileType: fileType, Source: source})
|
||||
} else if part.Type == "text" {
|
||||
texts = append(texts, part.GetText())
|
||||
} else {
|
||||
b, _ := kitutil.Marshal(part)
|
||||
texts = append(texts, string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,18 +412,6 @@ func (c *ClaudeRequest) SetModelName(modelName string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string {
|
||||
for _, message := range c.Messages {
|
||||
content, _ := message.ParseContent()
|
||||
for _, mediaMessage := range content {
|
||||
if mediaMessage.Id == toolCallId {
|
||||
return mediaMessage.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AddTool 添加工具到请求中
|
||||
func (c *ClaudeRequest) AddTool(tool any) {
|
||||
if c.Tools == nil {
|
||||
@@ -456,6 +465,18 @@ func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
|
||||
normalTools = append(normalTools, &t)
|
||||
case ClaudeWebSearchTool:
|
||||
webSearchTools = append(webSearchTools, &t)
|
||||
case map[string]any:
|
||||
// Tools decoded from client JSON arrive as maps.
|
||||
toolType, _ := t["type"].(string)
|
||||
if strings.HasPrefix(toolType, "web_search") {
|
||||
if webSearchTool, err := kitutil.Any2Type[ClaudeWebSearchTool](t); err == nil {
|
||||
webSearchTools = append(webSearchTools, &webSearchTool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if normalTool, err := kitutil.Any2Type[Tool](t); err == nil {
|
||||
normalTools = append(normalTools, &normalTool)
|
||||
}
|
||||
default:
|
||||
// 未知类型,跳过
|
||||
continue
|
||||
@@ -473,6 +494,50 @@ type Thinking struct {
|
||||
// the visible summary that was default on Opus 4.6; "omitted" (default on
|
||||
// 4.7) suppresses it. Pass-through field from upstream Anthropic API.
|
||||
Display string `json:"display,omitempty"`
|
||||
// BlockBinding carries the client's preserved-thinking controls, such as
|
||||
// {"prefix_mismatch_behavior":"drop_block"} (beta
|
||||
// thinking-binding-controls-2026-08-01). It is forwarded verbatim and
|
||||
// never added by the gateway.
|
||||
BlockBinding json.RawMessage `json:"block_binding,omitempty"`
|
||||
|
||||
// Extra keeps thinking fields this struct does not model yet, so a native
|
||||
// Claude request forwards them instead of silently dropping them. It is
|
||||
// exported so common.DeepCopy copies it; MarshalJSON writes it back.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (c *Thinking) UnmarshalJSON(data []byte) error {
|
||||
type thinkingFields Thinking
|
||||
var fields thinkingFields
|
||||
if err := kitutil.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
var all map[string]json.RawMessage
|
||||
if err := kitutil.Unmarshal(data, &all); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, known := range []string{"type", "budget_tokens", "display", "block_binding"} {
|
||||
delete(all, known)
|
||||
}
|
||||
*c = Thinking(fields)
|
||||
if len(all) > 0 {
|
||||
c.Extra = all
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Thinking) MarshalJSON() ([]byte, error) {
|
||||
type thinkingFields Thinking
|
||||
data, err := kitutil.Marshal(thinkingFields(c))
|
||||
if err != nil || len(c.Extra) == 0 {
|
||||
return data, err
|
||||
}
|
||||
merged := make(map[string]json.RawMessage, len(c.Extra)+4)
|
||||
maps.Copy(merged, c.Extra)
|
||||
if err := kitutil.Unmarshal(data, &merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kitutil.Marshal(merged)
|
||||
}
|
||||
|
||||
func (c *Thinking) GetBudgetTokens() int {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
// DeepSeekToolsRequest is a DeepSeek Search or Fetch API request
|
||||
// (/v1/tools/search, /v1/tools/fetch). The body names no model; Model holds
|
||||
// the routing name. RawBody preserves the original JSON so the body is
|
||||
// forwarded with unknown fields intact.
|
||||
type DeepSeekToolsRequest struct {
|
||||
Model string `json:"model,omitempty"`
|
||||
RawBody json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (r *DeepSeekToolsRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
return &types.TokenCountMeta{
|
||||
CombineText: string(r.RawBody),
|
||||
TokenType: types.TokenTypeTokenizer,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DeepSeekToolsRequest) IsStream(_ *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *DeepSeekToolsRequest) SetModelName(modelName string) {
|
||||
if modelName != "" {
|
||||
r.Model = modelName
|
||||
}
|
||||
}
|
||||
+52
-2
@@ -2,7 +2,9 @@ package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
@@ -74,13 +76,47 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
var inputTexts []string
|
||||
if r.SystemInstructions != nil {
|
||||
for _, part := range r.SystemInstructions.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, content := range r.Contents {
|
||||
for _, part := range content.Parts {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
if source := part.InlineData.ToFileSource(); source != nil {
|
||||
mimeType := part.InlineData.MimeType
|
||||
// Function calls and responses replayed in agent loops are part of the prompt.
|
||||
if part.FunctionCall != nil {
|
||||
args, _ := kitutil.Marshal(part.FunctionCall.Arguments)
|
||||
inputTexts = append(inputTexts, part.FunctionCall.FunctionName, string(args))
|
||||
}
|
||||
mediaParts := []GeminiPart{part}
|
||||
if part.FunctionResponse != nil {
|
||||
inputTexts = append(inputTexts, part.FunctionResponse.Name)
|
||||
// Count tool output as the text the model reads, not JSON-escaped.
|
||||
for _, key := range slices.Sorted(maps.Keys(part.FunctionResponse.Response)) {
|
||||
if text, ok := part.FunctionResponse.Response[key].(string); ok {
|
||||
inputTexts = append(inputTexts, text)
|
||||
continue
|
||||
}
|
||||
value, _ := kitutil.Marshal(part.FunctionResponse.Response[key])
|
||||
inputTexts = append(inputTexts, string(value))
|
||||
}
|
||||
// Multimodal function responses carry media such as agent screenshots.
|
||||
var responseParts []GeminiPart
|
||||
if kitutil.Unmarshal(part.FunctionResponse.Parts, &responseParts) == nil {
|
||||
mediaParts = append(mediaParts, responseParts...)
|
||||
}
|
||||
}
|
||||
for _, media := range mediaParts {
|
||||
source := media.InlineData.ToFileSource()
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
mimeType := media.InlineData.MimeType
|
||||
var fileType types.FileType
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
fileType = types.FileTypeImage
|
||||
@@ -91,13 +127,26 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
} else {
|
||||
fileType = types.FileTypeFile
|
||||
}
|
||||
// Image cost depends on the media resolution; a part's own level
|
||||
// overrides the request-wide one.
|
||||
detail := string(r.GenerationConfig.MediaResolution)
|
||||
var partResolution struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
if kitutil.Unmarshal(media.MediaResolution, &partResolution) == nil && partResolution.Level != "" {
|
||||
detail = partResolution.Level
|
||||
}
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.Tools) > 0 {
|
||||
inputTexts = append(inputTexts, string(r.Tools))
|
||||
}
|
||||
|
||||
inputText := strings.Join(inputTexts, "\n")
|
||||
return &types.TokenCountMeta{
|
||||
@@ -467,6 +516,7 @@ type GeminiChatCandidate struct {
|
||||
|
||||
type GeminiGroundingMetadata struct {
|
||||
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
|
||||
ImageSearchQueries []string `json:"imageSearchQueries,omitempty"`
|
||||
RetrievalQueries []string `json:"retrievalQueries,omitempty"`
|
||||
GroundingChunks json.RawMessage `json:"groundingChunks,omitempty"`
|
||||
GroundingSupports json.RawMessage `json:"groundingSupports,omitempty"`
|
||||
|
||||
@@ -93,6 +93,7 @@ type GeneralOpenAIRequest struct {
|
||||
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
|
||||
ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"`
|
||||
EnableSearch json.RawMessage `json:"enable_search,omitempty"`
|
||||
SearchOptions json.RawMessage `json:"search_options,omitempty"`
|
||||
// ollama Params
|
||||
Think json.RawMessage `json:"think,omitempty"`
|
||||
// baidu v2
|
||||
@@ -251,6 +252,14 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Assistant tool calls replayed in agent loops are part of the prompt.
|
||||
for _, toolCall := range message.ParseToolCalls() {
|
||||
if len(toolCall.Custom) > 0 {
|
||||
texts = append(texts, string(toolCall.Custom))
|
||||
continue
|
||||
}
|
||||
texts = append(texts, toolCall.Function.Name, toolCall.Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
tools := r.Tools
|
||||
@@ -259,12 +268,17 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
for _, tool := range tools {
|
||||
tokenCountMeta.ToolsCount++
|
||||
if len(tool.Custom) > 0 {
|
||||
texts = append(texts, string(tool.Custom))
|
||||
continue
|
||||
}
|
||||
texts = append(texts, tool.Function.Name)
|
||||
if tool.Function.Description != "" {
|
||||
texts = append(texts, tool.Function.Description)
|
||||
}
|
||||
if tool.Function.Parameters != nil {
|
||||
texts = append(texts, fmt.Sprintf("%v", tool.Function.Parameters))
|
||||
parameters, _ := kitutil.Marshal(tool.Function.Parameters)
|
||||
texts = append(texts, string(parameters))
|
||||
}
|
||||
}
|
||||
//toolTokens := CountTokenInput(countStr, request.Model)
|
||||
@@ -329,7 +343,8 @@ func GetOpenAIChatCapabilities(modelName, reasoningEffort string) OpenAIChatCapa
|
||||
}
|
||||
|
||||
isGPT5Model := IsOpenAIGPT5Model(modelName)
|
||||
if !isGPT5Model && !isOpenAIModelSnapshot(modelName, "gpt-6-astra") {
|
||||
isGPT6SolLuna := isOpenAIModelSnapshot(modelName, "gpt-6-sol") || isOpenAIModelSnapshot(modelName, "gpt-6-luna")
|
||||
if !isGPT5Model && !isGPT6SolLuna && !isOpenAIModelSnapshot(modelName, "gpt-6-astra") {
|
||||
return capabilities
|
||||
}
|
||||
capabilities.UseMaxCompletionTokens = true
|
||||
@@ -337,12 +352,15 @@ func GetOpenAIChatCapabilities(modelName, reasoningEffort string) OpenAIChatCapa
|
||||
|
||||
// These standard GPT-5 models default to none and support sampling only
|
||||
// without reasoning. Named variants (pro, codex, chat-latest, etc.) do not
|
||||
// inherit this exception. GPT-6 Astra never supports these parameters.
|
||||
// inherit this exception. GPT-6 Sol and Luna follow the same rule. GPT-6
|
||||
// Astra never supports these parameters.
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.2
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-5.4
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-astra
|
||||
// https://developers.openai.com/api/docs/guides/latest-model?model=gpt-6-luna
|
||||
supportsSampling := false
|
||||
if isGPT5Model && (reasoningEffort == "" || reasoningEffort == "none") {
|
||||
if reasoningEffort == "" || reasoningEffort == "none" {
|
||||
supportsSampling = isGPT6SolLuna
|
||||
for _, model := range []string{"gpt-5.1", "gpt-5.2", "gpt-5.4"} {
|
||||
if isOpenAIModelSnapshot(modelName, model) {
|
||||
supportsSampling = true
|
||||
@@ -390,6 +408,19 @@ type ToolCallRequest struct {
|
||||
Type string `json:"type"`
|
||||
Function FunctionRequest `json:"function"`
|
||||
Custom json.RawMessage `json:"custom,omitempty"`
|
||||
// Native is a complete vendor tool object sent instead of the fields
|
||||
// above. Function has no omitempty, so a non-function tool would
|
||||
// otherwise carry "function":{"name":""}, which vendor schemas with
|
||||
// additionalProperties:false reject. Never read from client JSON.
|
||||
Native json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
func (t ToolCallRequest) MarshalJSON() ([]byte, error) {
|
||||
if len(t.Native) > 0 {
|
||||
return t.Native, nil
|
||||
}
|
||||
type Alias ToolCallRequest
|
||||
return kitutil.Marshal((*Alias)(&t))
|
||||
}
|
||||
|
||||
type FunctionRequest struct {
|
||||
@@ -567,9 +598,11 @@ func (m *MediaContent) ToFileSource() types.FileSource {
|
||||
}
|
||||
|
||||
type MessageImageUrl struct {
|
||||
Url string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
MimeType string
|
||||
Url string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// MimeType is in-memory metadata for converters and token counting; it is
|
||||
// not part of the Chat image_url object.
|
||||
MimeType string `json:"-"`
|
||||
}
|
||||
|
||||
func (m *MessageImageUrl) IsRemoteImage() bool {
|
||||
@@ -743,9 +776,9 @@ func (m *Message) ParseContent() []MediaContent {
|
||||
|
||||
case ContentTypeImageURL:
|
||||
imageUrl := contentItem["image_url"]
|
||||
temp := &MessageImageUrl{
|
||||
Detail: "high",
|
||||
}
|
||||
// An omitted detail stays empty: upstream defaults it to auto, which
|
||||
// costs more than high on gpt-5.5 and later.
|
||||
temp := &MessageImageUrl{}
|
||||
switch v := imageUrl.(type) {
|
||||
case string:
|
||||
temp.Url = v
|
||||
@@ -1103,7 +1136,12 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
if len(r.Instructions) > 0 {
|
||||
texts = append(texts, string(r.Instructions))
|
||||
var instructions string
|
||||
if kitutil.GetJsonType(r.Instructions) == "string" && kitutil.Unmarshal(r.Instructions, &instructions) == nil {
|
||||
texts = append(texts, instructions)
|
||||
} else {
|
||||
texts = append(texts, string(r.Instructions))
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Metadata) > 0 {
|
||||
@@ -1162,6 +1200,11 @@ type Input struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
// Tool-call items replayed in agent loops.
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
ToolInput json.RawMessage `json:"input,omitempty"`
|
||||
Output json.RawMessage `json:"output,omitempty"`
|
||||
}
|
||||
|
||||
type MediaInput struct {
|
||||
@@ -1176,7 +1219,8 @@ type MediaInput struct {
|
||||
// Reference implementation mirrors Message.ParseContent:
|
||||
// - input can be a string, treated as an input_text item
|
||||
// - input can be an array of objects with a `type` field
|
||||
// supported types: input_text, input_image, input_file
|
||||
// supported types: input_text, output_text, input_image, input_file
|
||||
// - tool-call items contribute their name, arguments or input, and output as input_text
|
||||
func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
if r.Input == nil {
|
||||
return nil
|
||||
@@ -1201,15 +1245,28 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
var inputs []Input
|
||||
_ = kitutil.Unmarshal(r.Input, &inputs)
|
||||
for _, input := range inputs {
|
||||
if kitutil.GetJsonType(input.Content) == "string" {
|
||||
content := input.Content
|
||||
switch input.Type {
|
||||
case "function_call":
|
||||
content = input.Arguments
|
||||
case "custom_tool_call":
|
||||
content = input.ToolInput
|
||||
case "function_call_output", "custom_tool_call_output", "local_shell_call_output":
|
||||
content = input.Output
|
||||
}
|
||||
if input.Name != "" {
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: input.Name})
|
||||
}
|
||||
|
||||
if kitutil.GetJsonType(content) == "string" {
|
||||
var str string
|
||||
_ = kitutil.Unmarshal(input.Content, &str)
|
||||
_ = kitutil.Unmarshal(content, &str)
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: str})
|
||||
}
|
||||
|
||||
if kitutil.GetJsonType(input.Content) == "array" {
|
||||
if kitutil.GetJsonType(content) == "array" {
|
||||
var array []any
|
||||
_ = kitutil.Unmarshal(input.Content, &array)
|
||||
_ = kitutil.Unmarshal(content, &array)
|
||||
for _, itemAny := range array {
|
||||
// Already parsed MediaContent
|
||||
if media, ok := itemAny.(MediaInput); ok {
|
||||
@@ -1228,7 +1285,7 @@ func (r *OpenAIResponsesRequest) ParseInput() []MediaInput {
|
||||
continue
|
||||
}
|
||||
switch typeVal {
|
||||
case "input_text":
|
||||
case "input_text", "output_text":
|
||||
text, _ := item["text"].(string)
|
||||
mediaInputs = append(mediaInputs, MediaInput{Type: "input_text", Text: text})
|
||||
case "input_image":
|
||||
|
||||
@@ -374,7 +374,9 @@ type ResponsesOutput struct {
|
||||
Result string `json:"result,omitempty"`
|
||||
CallId string `json:"call_id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||
Input json.RawMessage `json:"input,omitempty"`
|
||||
Action json.RawMessage `json:"action,omitempty"`
|
||||
Queries json.RawMessage `json:"queries,omitempty"`
|
||||
Results json.RawMessage `json:"results,omitempty"`
|
||||
@@ -393,9 +395,24 @@ type ResponsesOutput struct {
|
||||
|
||||
// MarshalJSON keeps hosted-tool variants within their protocol-specific
|
||||
// schemas. ResponsesOutput also represents messages, images, and function
|
||||
// calls, whose fields must not leak into web_search_call or mcp_call items.
|
||||
// calls, whose fields must not leak into web_search_call, mcp_call, or
|
||||
// custom_tool_call items.
|
||||
func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
|
||||
switch r.Type {
|
||||
case "custom_tool_call":
|
||||
input := r.Input
|
||||
if len(input) == 0 {
|
||||
input = json.RawMessage(`""`)
|
||||
}
|
||||
return kitutil.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
CallID string `json:"call_id"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}{Type: r.Type, ID: r.ID, Status: r.Status, CallID: r.CallId, Name: r.Name, Namespace: r.Namespace, Input: input})
|
||||
case "web_search_call":
|
||||
return kitutil.Marshal(struct {
|
||||
Type string `json:"type"`
|
||||
@@ -425,6 +442,16 @@ func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
|
||||
Error: r.ItemError,
|
||||
ApprovalRequestID: r.ApprovalRequestID,
|
||||
})
|
||||
case "reasoning":
|
||||
// summary is required on a reasoning item, even before any summary text exists.
|
||||
type responsesOutputAlias ResponsesOutput
|
||||
if len(r.Summary) > 0 {
|
||||
return kitutil.Marshal(responsesOutputAlias(r))
|
||||
}
|
||||
return kitutil.Marshal(struct {
|
||||
responsesOutputAlias
|
||||
Summary []ResponsesReasoningSummaryPart `json:"summary"`
|
||||
}{responsesOutputAlias(r), []ResponsesReasoningSummaryPart{}})
|
||||
default:
|
||||
type responsesOutputAlias ResponsesOutput
|
||||
return kitutil.Marshal(responsesOutputAlias(r))
|
||||
@@ -555,6 +582,7 @@ type ResponsesStreamResponse struct {
|
||||
Param string `json:"param,omitempty"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Arguments *string `json:"arguments,omitempty"`
|
||||
Input *string `json:"input,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
Item *ResponsesOutput `json:"item,omitempty"`
|
||||
@@ -564,6 +592,8 @@ type ResponsesStreamResponse struct {
|
||||
Obfuscation string `json:"obfuscation,omitempty"`
|
||||
// - response.function_call_arguments.delta
|
||||
// - response.function_call_arguments.done
|
||||
// - response.custom_tool_call_input.delta
|
||||
// - response.custom_tool_call_input.done
|
||||
OutputIndex *int `json:"output_index,omitempty"`
|
||||
ContentIndex *int `json:"content_index,omitempty"`
|
||||
SummaryIndex *int `json:"summary_index,omitempty"`
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Agent loops replay tool definitions, tool calls, and tool output on every
|
||||
// turn; the prompt estimate must see all of them as the model reads them.
|
||||
func TestTokenCountMetaCountsAgentLoopContent(t *testing.T) {
|
||||
const toolOutput = "package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"
|
||||
const imageData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
raw string
|
||||
request interface{ GetTokenCountMeta() *types.TokenCountMeta }
|
||||
contains []string
|
||||
files int
|
||||
detail string
|
||||
toolCount int
|
||||
}{
|
||||
{
|
||||
name: "chat",
|
||||
raw: `{"model":"m","messages":[
|
||||
{"role":"user","content":[{"type":"text","text":"show main.go"},{"type":"image_url","image_url":{"url":"data:image/png;base64,` + imageData + `"}}]},
|
||||
{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"main.go\"}"}}]},
|
||||
{"role":"tool","tool_call_id":"c1","content":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"}],
|
||||
"tools":[{"type":"function","function":{"name":"read_file","description":"Read a file","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}}]}`,
|
||||
request: &GeneralOpenAIRequest{},
|
||||
contains: []string{"read_file", `{"path":"main.go"}`, toolOutput, "Read a file", `"properties":{"path":{"type":"string"}}`},
|
||||
// An omitted detail stays empty so the estimate uses the upstream default (auto).
|
||||
files: 1,
|
||||
toolCount: 1,
|
||||
},
|
||||
{
|
||||
name: "responses",
|
||||
raw: `{"model":"m","instructions":"Be brief.\nUse tools.","input":[
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"show main.go"}]},
|
||||
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Reading it now."}]},
|
||||
{"type":"function_call","call_id":"c1","name":"read_file","arguments":"{\"path\":\"main.go\"}"},
|
||||
{"type":"function_call_output","call_id":"c1","output":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"},
|
||||
{"type":"custom_tool_call","call_id":"c2","name":"apply_patch","input":"*** Begin Patch"},
|
||||
{"type":"custom_tool_call_output","call_id":"c2","output":[{"type":"input_text","text":"Done!"}]}]}`,
|
||||
request: &OpenAIResponsesRequest{},
|
||||
contains: []string{"Be brief.\nUse tools.", "Reading it now.", "read_file", `{"path":"main.go"}`, toolOutput, "apply_patch", "*** Begin Patch", "Done!"},
|
||||
},
|
||||
{
|
||||
name: "claude",
|
||||
raw: `{"model":"m","max_tokens":1,"messages":[
|
||||
{"role":"user","content":"show main.go"},
|
||||
{"role":"assistant","content":[{"type":"tool_use","id":"c1","name":"read_file","input":{"path":"main.go"}}]},
|
||||
{"role":"user","content":[
|
||||
{"type":"tool_result","tool_use_id":"c1","content":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}"},
|
||||
{"type":"tool_result","tool_use_id":"c2","content":[{"type":"text","text":"screenshot taken"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + imageData + `"}}]}]}],
|
||||
"tools":[{"name":"read_file","description":"Read a file","input_schema":{"type":"object","properties":{"path":{"type":"string"}}}}]}`,
|
||||
request: &ClaudeRequest{},
|
||||
contains: []string{"read_file", `{"path":"main.go"}`, toolOutput, "screenshot taken", "Read a file", `"properties":{"path":{"type":"string"}}`},
|
||||
files: 1,
|
||||
toolCount: 1,
|
||||
},
|
||||
{
|
||||
name: "gemini",
|
||||
raw: `{"systemInstruction":{"parts":[{"text":"Be brief."}]},"contents":[
|
||||
{"role":"user","parts":[{"text":"show main.go"}]},
|
||||
{"role":"model","parts":[{"functionCall":{"name":"read_file","args":{"path":"main.go"}}}]},
|
||||
{"role":"user","parts":[{"functionResponse":{"name":"read_file","response":{"output":"package main\n\nfunc main() {\n\tprintln(\"hi\")\n}","exit_code":0}}}]},
|
||||
{"role":"user","parts":[{"functionResponse":{"name":"screenshot","response":{},"parts":[{"inlineData":{"mimeType":"image/png","data":"` + imageData + `"}}]}}]}],
|
||||
"generationConfig":{"mediaResolution":"MEDIA_RESOLUTION_LOW"},
|
||||
"tools":[{"functionDeclarations":[{"name":"read_file","description":"Read a file"}]}]}`,
|
||||
request: &GeminiChatRequest{},
|
||||
contains: []string{"Be brief.", "read_file", `{"path":"main.go"}`, toolOutput, "Read a file"},
|
||||
files: 1,
|
||||
detail: "MEDIA_RESOLUTION_LOW",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, kitutil.Unmarshal([]byte(tc.raw), tc.request))
|
||||
meta := tc.request.GetTokenCountMeta()
|
||||
for _, want := range tc.contains {
|
||||
assert.Contains(t, meta.CombineText, want)
|
||||
}
|
||||
// Tool output and instructions are counted as raw text, not JSON-escaped,
|
||||
// and images stay out of the text.
|
||||
assert.NotContains(t, meta.CombineText, `\n`)
|
||||
assert.NotContains(t, meta.CombineText, "map[")
|
||||
assert.NotContains(t, meta.CombineText, imageData)
|
||||
require.Len(t, meta.Files, tc.files)
|
||||
for _, file := range meta.Files {
|
||||
assert.Equal(t, types.FileTypeImage, file.FileType)
|
||||
assert.Equal(t, tc.detail, file.Detail)
|
||||
}
|
||||
assert.Equal(t, tc.toolCount, meta.ToolsCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
+18
-13
@@ -264,23 +264,28 @@ func normalizeGeminiModality(modality string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(modality))
|
||||
}
|
||||
|
||||
// mergeGeminiTokenDetails overlays per-modality counts the same way as the
|
||||
// scalar counts: every stream frame carries a cumulative snapshot, so a
|
||||
// modality the incoming frame reports replaces all earlier entries for it,
|
||||
// and a modality it omits keeps its earlier value. Duplicate entries inside
|
||||
// one snapshot are kept as-is; settlement sums them.
|
||||
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
|
||||
merged := append([]GeminiPromptTokensDetails{}, current...)
|
||||
indexes := make(map[string]int, len(merged))
|
||||
for index, detail := range merged {
|
||||
indexes[normalizeGeminiModality(detail.Modality)] = index
|
||||
reported := make(map[string]bool, len(incoming))
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount > 0 {
|
||||
reported[normalizeGeminiModality(detail.Modality)] = true
|
||||
}
|
||||
}
|
||||
merged := make([]GeminiPromptTokensDetails, 0, len(current)+len(incoming))
|
||||
for _, detail := range current {
|
||||
if !reported[normalizeGeminiModality(detail.Modality)] {
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
}
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount <= 0 {
|
||||
continue
|
||||
if detail.TokenCount > 0 {
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
key := normalizeGeminiModality(detail.Modality)
|
||||
if index, ok := indexes[key]; ok {
|
||||
merged[index].TokenCount += detail.TokenCount
|
||||
continue
|
||||
}
|
||||
indexes[key] = len(merged)
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -72,37 +72,95 @@ func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T
|
||||
})
|
||||
}
|
||||
|
||||
// A case variant in a later cumulative frame is the same modality and
|
||||
// replaces the earlier value.
|
||||
mergedDetails := mergeGeminiTokenDetails(
|
||||
[]GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||
[]GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||
)
|
||||
require.Len(t, mergedDetails, 1)
|
||||
assert.Equal(t, 25, mergedDetails[0].TokenCount)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}}, mergedDetails)
|
||||
|
||||
streamMerged := MergeGeminiUsageMetadataNonZero(
|
||||
&GeminiUsageMetadata{
|
||||
PromptTokenCount: 10,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||
},
|
||||
&GeminiUsageMetadata{
|
||||
PromptTokenCount: 25,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||
},
|
||||
)
|
||||
require.NotNil(t, streamMerged)
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
|
||||
decodedUsage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
// Duplicate entries inside one snapshot settle as their sum, and merging
|
||||
// that snapshot again (every frame repeats it) does not add it twice.
|
||||
snapshot := &GeminiUsageMetadata{
|
||||
PromptTokenCount: 25,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "AUDIO", TokenCount: 10},
|
||||
{Modality: "audio", TokenCount: 15},
|
||||
},
|
||||
}).CanonicalUsage()
|
||||
}
|
||||
streamMerged := MergeGeminiUsageMetadataNonZero(MergeGeminiUsageMetadataNonZero(nil, snapshot), snapshot)
|
||||
require.NotNil(t, streamMerged)
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
|
||||
decodedUsage, ok := NewGeminiChatBillingUsage(snapshot).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
}
|
||||
|
||||
func TestMergeGeminiUsageMetadataStreamDetailsAreCumulativeSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := []GeminiPromptTokensDetails{
|
||||
{Modality: "IMAGE", TokenCount: 1089},
|
||||
{Modality: "TEXT", TokenCount: 42},
|
||||
}
|
||||
frames := []*GeminiUsageMetadata{
|
||||
{PromptTokenCount: 1131, TotalTokenCount: 1131, PromptTokensDetails: prompt},
|
||||
{
|
||||
PromptTokenCount: 1131,
|
||||
ToolUsePromptTokenCount: 7,
|
||||
CandidatesTokenCount: 5,
|
||||
TotalTokenCount: 1143,
|
||||
PromptTokensDetails: prompt,
|
||||
ToolUsePromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}},
|
||||
CandidatesTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 5}},
|
||||
},
|
||||
{
|
||||
PromptTokenCount: 1131,
|
||||
ToolUsePromptTokenCount: 7,
|
||||
CandidatesTokenCount: 1310,
|
||||
ThoughtsTokenCount: 76,
|
||||
TotalTokenCount: 2524,
|
||||
PromptTokensDetails: prompt,
|
||||
ToolUsePromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}},
|
||||
CandidatesTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 20},
|
||||
{Modality: "IMAGE", TokenCount: 1290},
|
||||
},
|
||||
},
|
||||
// A trailing frame without details keeps the last reported ones.
|
||||
{PromptTokenCount: 1131, CandidatesTokenCount: 1310, ThoughtsTokenCount: 76, TotalTokenCount: 2524},
|
||||
}
|
||||
var merged *GeminiUsageMetadata
|
||||
for _, frame := range frames {
|
||||
merged = MergeGeminiUsageMetadataNonZero(merged, frame)
|
||||
}
|
||||
require.NotNil(t, merged)
|
||||
assert.Equal(t, prompt, merged.PromptTokensDetails)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}}, merged.ToolUsePromptTokensDetails)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 20},
|
||||
{Modality: "IMAGE", TokenCount: 1290},
|
||||
}, merged.CandidatesTokensDetails)
|
||||
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(merged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
nonStreamUsage, ok := NewGeminiChatBillingUsage(frames[2]).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, nonStreamUsage, streamUsage)
|
||||
assert.Equal(t, 1089, streamUsage.PromptTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 1290, streamUsage.CompletionTokenDetails.ImageTokens)
|
||||
|
||||
// Converted streams remember the same cumulative sidecar at several
|
||||
// pipeline steps; repeated merges must not change the result.
|
||||
sidecar := NewGeminiChatBillingUsage(merged)
|
||||
again := MergeBillingUsageNonZero(MergeBillingUsageNonZero(sidecar, sidecar), sidecar)
|
||||
repeatedUsage, ok := again.CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, nonStreamUsage, repeatedUsage)
|
||||
}
|
||||
|
||||
func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
|
||||
@@ -172,8 +230,8 @@ func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
CachedContentTokenCount: 20,
|
||||
})
|
||||
incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
type UserSetting struct {
|
||||
NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型
|
||||
QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值
|
||||
QuotaWarningEnabled *bool `json:"quota_warning_enabled,omitempty"` // 是否接收余额不足提醒,未设置时接收
|
||||
WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址
|
||||
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
|
||||
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
|
||||
@@ -11,6 +12,7 @@ type UserSetting struct {
|
||||
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
|
||||
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
|
||||
UpstreamModelUpdateNotifyEnabled bool `json:"upstream_model_update_notify_enabled,omitempty"` // 是否接收上游模型更新定时检测通知(仅管理员)
|
||||
ChannelNotifyEnabled *bool `json:"channel_notify_enabled,omitempty"` // 是否接收渠道通知(仅管理员),未设置时 root 接收、其他管理员不接收
|
||||
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
||||
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
||||
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
||||
|
||||
Reference in new issue
Block a user