feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+65 -4
View File
@@ -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
}
+4
View File
@@ -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
View File
@@ -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 {
+34
View File
@@ -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
View File
@@ -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"`
+74 -17
View File
@@ -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":
+31 -1
View File
@@ -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"`
+100
View File
@@ -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
View File
@@ -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
}
+79 -21
View File
@@ -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{
+2
View File
@@ -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 左侧边栏模块配置